PostgreSQL Declarative Partitioning 与大型数据生命周期
从声明式分区、分区键、分区裁剪、分区索引、ATTACH、DETACH、冷热分层与维护任务出发,理解 PostgreSQL 大型数据生命周期管理。
第 14 章:Declarative Partitioning 与大型数据生命周期
技术基线:PostgreSQL 18;同时标注 PostgreSQL 14、15、16、17、18 的重要差异。Go 示例使用
github.com/jackc/pgx/v5与pgxpool。本章核心结论:分区首先是一种数据生命周期、故障域和物理布局管理手段,其次才可能带来查询性能收益。它不等于索引,不等于分片,也不是“大表”的自动答案。
1. 本章定位
本章解决四类生产问题:
- 如何让超大表仍可维护:按时间、租户或散列键把数据拆成可独立创建、装载、备份、归档、迁移和删除的物理表。
- 如何减少无关数据访问:让 Planner 或 Executor 根据分区边界执行 Partition Pruning,只访问可能包含目标行的分区。
- 如何管理数据生命周期:预建未来分区、隔离热数据与冷数据、按保留期
DETACH或DROP历史分区,而不是逐行删除。 - 如何控制分区带来的新风险:规划时间、会话内存、DDL 锁、Default Partition、跨分区唯一性、统计信息、Autovacuum、复制和恢复复杂度。
1.1 与前后章节的关系
- 第 3~8 章提供 Page、Tuple、索引、Planner、Executor 和统计信息基础;本章把这些能力应用到一个由多个物理关系组成的逻辑表。
- 第 9~13 章提供 MVCC、锁、VACUUM、WAL 和恢复基础;本章解释逐行
DELETE与元数据级DETACH/DROP为什么具有完全不同的并发和 WAL 特征。 - 第 15 章继续讨论在线 DDL。分区维护虽常被称为“轻量操作”,但仍可能请求强锁、排队并形成锁队列。
- 第 16~19 章将使用本章模型实现独立维护任务、容量规划,以及区分单机分区与跨节点分片。
1.2 本章不展开的内容
- 不把传统继承分区作为主方案;生产新系统优先使用 Declarative Partitioning。
- 不展开 Citus、分布式 PostgreSQL 或自研路由层;本章只说明分区与分片的边界。
- 不替代第 15 章的完整零停机 DDL 方法,也不替代第 20~23 章的备份、复制和高可用设计。
- 不给出适用于所有机器的固定“每个分区多少行”结论。粒度必须由查询窗口、保留策略、索引大小、维护时限与规划开销共同决定。
2. 可验证的学习目标
完成本章后,你应能够:
- 为给定业务在
RANGE、LIST、HASH及多级分区之间做出可解释的选择。 - 写出边界无重叠、无空洞或有意保留空洞的分区 DDL,并解释 Range 下界包含、上界不包含的语义。
- 使用
EXPLAIN (ANALYZE, BUFFERS, WAL, SETTINGS, VERBOSE, SUMMARY)区分规划期裁剪、初始化期裁剪和执行期裁剪。 - 解释为什么参数化查询仍可能执行运行期裁剪,以及 Generic Plan 与 Custom Plan 对计划形状的影响。
- 判断一条查询为什么访问一个、多个或全部分区,并改写谓词以匹配分区边界。
- 设计分区索引、主键、唯一约束和外键,并准确说明 PostgreSQL 18 没有通用全局唯一索引的限制。
- 使用
ATTACH PARTITION、DETACH PARTITION、DETACH PARTITION CONCURRENTLY和DROP TABLE管理生命周期,并预测其锁行为。 - 在不伪造耗时的前提下,对比历史分区
DROP/DETACH与批量DELETE的 Buffers、WAL、死元组和延迟分位数。 - 解释分区父表、叶分区、分区索引、
pg_partitioned_table、pg_inherits、pg_class.relpartbound之间的关系。 - 为分区树建立统计信息和 Autovacuum 策略,并解释为什么需要定期手工
ANALYZE分区父表。 - 识别过度分区、Default Partition 膨胀、遗漏未来分区、DDL 阻塞和全局 ID 冲突等事故模式。
- 使用 Go、pgx/v5 与 pgxpool 实现独立、单实例、可取消、带锁超时的未来分区维护任务,而不在普通请求路径执行 DDL。
3. 核心术语
| 中文名称 | English | 准确定义 | 容易混淆的概念 | 所属层次 |
|---|---|---|---|---|
| 声明式分区 | Declarative Partitioning | 由 PostgreSQL 根据分区键和边界管理路由、裁剪及层级关系的内建分区机制 | 传统继承分区、分片 | Schema / Executor |
| 分区表 | Partitioned Table | 没有自身 Heap 存储、作为分区树根或中间节点的虚拟关系 | 普通表、视图 | Catalog / Relcache |
| 叶分区 | Leaf Partition | 真正存储 Heap Tuple 和本地索引的普通表或外部表 | 分区父表 | Storage |
| 分区键 | Partition Key | 用于计算目标分区、证明查询与边界是否相交的列或表达式 | 主键、索引键 | Schema / Planner |
| 分区边界 | Partition Bound | 描述某个分区可接收键空间的元数据约束 | 用户定义 CHECK | Catalog |
| 范围分区 | Range Partitioning | 按有序、互不重叠的半开区间划分数据 | 范围索引扫描 | Schema |
| 列表分区 | List Partitioning | 将明确列出的离散值集合映射到分区 | 枚举类型 | Schema |
| 哈希分区 | Hash Partitioning | 按分区键哈希结果的 modulus/remainder 划分数据 | 哈希索引、跨节点一致性哈希 | Schema |
| 多级分区 | Sub-partitioning | 叶候选节点继续声明为分区表,形成两级或更多层级 | 分片副本 | Schema / Planner |
| 默认分区 | Default Partition | 接收未匹配其他 Range/List 分区边界的行;Hash 不支持 Default | 错误队列、兜底表 | Schema / Operations |
| 元组路由 | Tuple Routing | INSERT 或跨边界 UPDATE 时沿分区树选择目标叶分区 | 应用层路由 | Executor |
| 分区裁剪 | Partition Pruning | 根据分区边界排除不可能包含结果的分区 | 索引扫描、Constraint Exclusion | Planner / Executor |
| 规划期裁剪 | Planning-Time Pruning | Planner 在构建计划时利用已知常量排除分区 | 执行期裁剪 | Planner |
| 初始化期裁剪 | Initialization Pruning | Executor 初始化计划时利用执行参数排除子计划 | 规划期裁剪 | Executor |
| 动态执行期裁剪 | Dynamic Execution-Time Pruning | 参数在执行中变化时反复重新计算可用分区,例如参数化 Nested Loop 内侧 | Generic Plan | Executor |
| 参数化查询 | Parameterized Query | 谓词值由 $1 等参数提供;可使用 Custom Plan 或 Generic Plan | 字符串拼接 SQL | Protocol / Planner |
| 分区索引 | Partitioned Index | 父表上的虚拟索引及每个叶分区上的真实本地索引集合 | 全局索引 | Catalog / Index |
| 全局唯一索引 | Global Unique Index | 单个索引跨所有分区验证唯一性;PostgreSQL 18 核心未提供通用实现 | 父级唯一约束 | Index |
| 分区级连接 | Partition-Wise Join | 将两个兼容分区表按匹配子分区分别连接,再组合结果 | Parallel Join | Planner / Executor |
| 分区级聚合 | Partition-Wise Aggregate | 在各分区先聚合,必要时再做最终聚合 | Parallel Aggregate | Planner / Executor |
| 附加分区 | ATTACH PARTITION | 将已有表验证后纳入分区树 | CREATE TABLE ... PARTITION OF | DDL / Locking |
| 分离分区 | DETACH PARTITION | 将分区从树中移除并保留为独立表 | DROP TABLE | DDL / Lifecycle |
| 并发分离 | DETACH … CONCURRENTLY | 通过两个内部事务和较弱父表锁降低阻塞的分离方式 | CREATE INDEX CONCURRENTLY | DDL / Locking |
| 待分离状态 | Detach Pending | DETACH CONCURRENTLY 第一阶段已标记、第二阶段尚未完成的目录状态 | Invalid Index | Catalog / Recovery |
| 冷热分层 | Hot/Cold Tiering | 将近期高频访问数据与历史低频访问数据放在不同分区、索引或 Tablespace | 缓存淘汰 | Storage / Operations |
| 过度分区 | Over-partitioning | 分区数量或层级超过工作负载收益,导致规划、目录、锁和内存成本上升 | 大表本身 | Architecture |
| 数据分片 | Sharding | 将数据分布到不同 PostgreSQL 实例或节点,突破单节点资源边界 | 单实例分区 | Distributed Architecture |
4. 整体心智模型

4.1 数据流
- 分区父表本身不存储行。写入父表时,Executor 根据分区键值选择叶分区;Heap Tuple、TOAST 数据和真实 Index Tuple 均写入叶分区。
- 查询父表时,逻辑上访问整个树;实际执行多少叶分区由裁剪能力、谓词、参数、连接条件和计划决定。
- 跨分区查询不是把所有分区“合并成一个文件”,而是生成多个子计划并通过
Append、Merge Append、并行 Append、分区级连接或聚合组合结果。
4.2 控制流
- Planner 先读取分区元数据并尝试规划期裁剪。
- 对计划时未知的参数,Executor 可在初始化时或参数变化时执行运行期裁剪。
ATTACH、DETACH和DROP不是普通 DML,而是目录结构变更;它们需要表级锁,并使其他会话中的 Relcache 或缓存计划重新验证。
4.3 状态变化
ATTACH:独立表 → 验证列、约束、边界和索引 → 分区树成员。- 普通
DETACH:分区树成员 → 独立表。 DETACH CONCURRENTLY:正常分区 →inhdetachpending状态 → 等待旧事务 → 独立表;中断后可用FINALIZE完成。DROP:分区树成员及其物理文件和目录对象被删除;恢复依赖备份与 WAL,而不是“撤销文件删除”。
4.4 故障路径
- 没有匹配分区且无 Default:写入直接失败,通常是维护任务遗漏或时间边界错误。
- 有 Default:写入成功但可能掩盖遗漏,后续添加正式分区时 Default 扫描、数据迁移和强锁成为事故点。
- 分区过多或谓词不能裁剪:规划时间、每会话元数据内存和锁数量上升,P99 可在执行前就恶化。
- 长事务持有历史分区锁:
DROP、普通DETACH或DETACH CONCURRENTLY第二阶段等待,后续 DDL/DML 可能形成锁队列。
5. 使用方式
5.1 Range、List、Hash 的最小正确示例
5.1.1 Range:时间序列与生命周期
CREATE SCHEMA IF NOT EXISTS app;
CREATE TABLE app.event_log (
tenant_id bigint NOT NULL,
event_id uuid NOT NULL,
occurred_at timestamptz NOT NULL,
event_type text NOT NULL,
payload jsonb NOT NULL,
ingested_at timestamptz NOT NULL DEFAULT clock_timestamp(),
PRIMARY KEY (tenant_id, occurred_at, event_id)
) PARTITION BY RANGE (occurred_at);
CREATE TABLE app.event_log_2026_06
PARTITION OF app.event_log
FOR VALUES FROM ('2026-06-01 00:00:00+00')
TO ('2026-07-01 00:00:00+00');
CREATE TABLE app.event_log_2026_07
PARTITION OF app.event_log
FOR VALUES FROM ('2026-07-01 00:00:00+00')
TO ('2026-08-01 00:00:00+00');
Range 边界是半开区间 [FROM, TO):下界包含,上界不包含。相邻月可共享边界值,2026-07-01 00:00:00+00 只属于七月分区。
时间分区必须统一边界语义。建议将保留策略和边界统一到 UTC;业务展示时区由应用处理。否则夏令时地区的“本地一天”可能不是固定 24 小时。
5.1.2 List:离散、稳定、低基数域
CREATE TABLE app.customer_profile (
region_code text NOT NULL,
customer_id bigint NOT NULL,
profile jsonb NOT NULL,
PRIMARY KEY (region_code, customer_id)
) PARTITION BY LIST (region_code);
CREATE TABLE app.customer_profile_apac
PARTITION OF app.customer_profile
FOR VALUES IN ('JP', 'SG', 'AU');
CREATE TABLE app.customer_profile_emea
PARTITION OF app.customer_profile
FOR VALUES IN ('DE', 'FR', 'GB');
CREATE TABLE app.customer_profile_other
PARTITION OF app.customer_profile DEFAULT;
List 适合值域明确且变化缓慢的区域、业务线、状态族。不适合“每个客户一个分区”:客户数增长会将目录对象数与规划开销绑定到业务增长。
5.1.3 Hash:均匀分散,不提供时间删除边界
CREATE TABLE app.idempotency_record (
idempotency_key text NOT NULL,
response_code integer NOT NULL,
response_body jsonb NOT NULL,
created_at timestamptz NOT NULL,
PRIMARY KEY (idempotency_key)
) PARTITION BY HASH (idempotency_key);
CREATE TABLE app.idempotency_record_p0
PARTITION OF app.idempotency_record
FOR VALUES WITH (MODULUS 8, REMAINDER 0);
-- p1 ... p7 分别使用 remainder 1 ... 7
这里 PRIMARY KEY (idempotency_key) 合法,因为唯一键包含全部分区键。Hash 可分散写入和缩小本地索引,但按时间清理仍会触及每个 Hash 分区,不能像时间 Range 一样整分区淘汰。
5.2 多级分区
典型组合是“先按时间管理生命周期,再按租户 Hash 分散单月热点”:
CREATE TABLE app.metric_sample (
tenant_id bigint NOT NULL,
sampled_at timestamptz NOT NULL,
metric_id bigint NOT NULL,
value double precision NOT NULL,
PRIMARY KEY (sampled_at, tenant_id, metric_id)
) PARTITION BY RANGE (sampled_at);
CREATE TABLE app.metric_sample_2026_06
PARTITION OF app.metric_sample
FOR VALUES FROM ('2026-06-01 00:00:00+00')
TO ('2026-07-01 00:00:00+00')
PARTITION BY HASH (tenant_id);
CREATE TABLE app.metric_sample_2026_06_h0
PARTITION OF app.metric_sample_2026_06
FOR VALUES WITH (MODULUS 4, REMAINDER 0);
-- h1 ... h3
多级分区会使叶分区数相乘。若保留 36 个月、每月 16 个 Hash 子分区,就是 576 个叶分区;再为每个叶分区配置 5 个索引,会产生 2,880 个真实索引。设计前必须计算完整对象数量,而不是只看第一层。
5.3 Partition Key 的选择原则
优先级通常是:
- 生命周期边界:能否把同一批删除/归档的数据放入同一分区。
- 高频谓词:大多数查询是否显式包含与边界兼容的分区键条件。
- 唯一性要求:主键或唯一约束是否能包含全部分区键。
- 数据倾斜:单个分区是否会成为容量、I/O、锁或 Autovacuum 热点。
- 未来增长:租户、状态值或日期跨度增长后,分区数量是否仍可控。
错误示例:业务总按 tenant_id 点查,却只按 occurred_at 分区,且点查 SQL 不携带时间范围。即使 event_id 在每个分区都有索引,数据库仍需在许多分区中分别探测本地索引。
5.4 Partition Bound 与表达式
可使用列或表达式作为分区键,但表达式分区键会直接限制父级 UNIQUE/PRIMARY KEY 的定义。生产上优先使用简单、稳定、可直接出现在谓词中的列。
-- 可行,但会让唯一约束与谓词证明更复杂,不是默认首选。
CREATE TABLE app.daily_rollup (
occurred_at timestamptz NOT NULL,
tenant_id bigint NOT NULL,
amount numeric NOT NULL
) PARTITION BY RANGE ((occurred_at AT TIME ZONE 'UTC')::date);
更常见的设计是增加一个由应用或受控生成逻辑写入的 event_date date,并明确校验其与时间戳一致;这样边界、约束和 SQL 更可读。不要通过可变时区设置使同一时间戳在不同会话落入不同逻辑日期。
5.5 Default Partition:兜底还是债务入口
CREATE TABLE app.event_log_default
PARTITION OF app.event_log DEFAULT;
Default 适合短期防止写入中断,但必须把它视为异常缓冲区:
- 它会掩盖未来分区未创建、边界错误或脏时间数据。
- 新建或 Attach 正式分区时,PostgreSQL 必须证明 Default 中没有属于新边界的行;没有可证明的
CHECK约束时会扫描 Default,并对它获取ACCESS EXCLUSIVE锁。 - Default 一旦很大,会把本应简单的月度维护变成扫描、迁移和锁事故。
- 父表存在 Default 时不能使用
DETACH PARTITION ... CONCURRENTLY。 - Hash 分区不支持 Default。
生产建议:若业务能接受“快速失败”,时间序列表通常宁可不建 Default,并对 SQLSTATE 和“未来分区剩余天数”告警;若必须建 Default,则监控行数、最早/最晚键值,目标应长期为零。
5.6 Tuple Routing 与跨分区 UPDATE
INSERT INTO app.event_log (
tenant_id, event_id, occurred_at, event_type, payload
) VALUES ($1, $2, $3, $4, $5);
应用只写根表。PostgreSQL 根据 $3 沿分区树路由。不要让普通业务代码拼接具体分区名:这会绕开统一约束、增加注入风险、让迁移和重分区困难。
更新分区键可能移动行:
UPDATE app.event_log
SET occurred_at = $1
WHERE tenant_id = $2
AND occurred_at >= $3
AND occurred_at < $4
AND event_id = $5;
若新值不再满足原分区边界,Executor 将行移动到目标分区。其代价接近删除旧版本并向新叶分区插入新版本:涉及两个 Heap、两组本地索引、更多 WAL、更多锁与触发器/FK 语义。高并发系统应尽量让分区键不可变。
5.7 查询形式与 Partition Pruning
推荐:直接使用与边界同型的半开区间
SELECT tenant_id, event_id, occurred_at, event_type
FROM app.event_log
WHERE occurred_at >= $1
AND occurred_at < $2
AND tenant_id = $3
ORDER BY occurred_at, event_id;
风险:只对分区键套函数
-- 可能不能按原始 timestamptz Range 边界完成有效裁剪。
SELECT count(*)
FROM app.event_log
WHERE date_trunc('month', occurred_at) = $1;
可改为由应用计算边界并传入 [month_start, next_month_start)。这也避免数据库逐行调用函数。
为什么“不包含分区键”会扫描大量分区
分区裁剪依据的是分区边界,不是本地索引。查询若只有 event_id = $1,Planner 无法证明该 ID 不存在于某个月,因此每个月都可能包含目标行。结果可能是对每个叶分区执行一次本地 Index Scan;有 120 个月就可能产生 120 次索引探测、120 组关系锁和元数据访问。
5.8 规划期、初始化期与动态执行期裁剪
EXPLAIN (ANALYZE, BUFFERS, WAL, SETTINGS, VERBOSE, SUMMARY)
SELECT count(*)
FROM app.event_log
WHERE occurred_at >= TIMESTAMPTZ '2026-06-01 00:00:00+00'
AND occurred_at < TIMESTAMPTZ '2026-07-01 00:00:00+00';
- 规划期裁剪:常量在 Plan 阶段已知,无关分区通常根本不出现在最终计划中。
- 初始化期裁剪:Generic Plan 或参数在执行初始化时才已知;
EXPLAIN可显示Subplans Removed。被初始化期裁剪的分区仍可能在执行开始时被锁定。 - 动态执行期裁剪:参数化 Nested Loop 的内侧参数随外侧行变化;检查各子计划的
loops与(never executed)。
参数化查询不会天然破坏裁剪。需要同时检查:
SHOW plan_cache_mode;
SELECT name, statement, generic_plans, custom_plans
FROM pg_prepared_statements;
plan_cache_mode = auto 通常应保留。Generic Plan 可节省重复规划成本,仍能执行初始化/执行期裁剪;但若不同参数对应的数据量差异巨大,Custom Plan 可能更准确。不要在全局层面武断强制 Custom Plan,应对目标 SQL 做基准和计划验证。
5.9 分区索引
CREATE INDEX event_log_tenant_time_idx
ON app.event_log (tenant_id, occurred_at DESC)
INCLUDE (event_type);
父表索引是虚拟对象;PostgreSQL 为现有叶分区创建真实索引,未来新建或 Attach 的分区也必须具备可附加的匹配索引。
分区不替代索引:
- 裁剪回答“哪些分区不可能有数据”;
- 索引回答“在剩余分区内如何快速找到少量行”。
若查询通常扫描整月的 70%,该月顺序扫描可能优于索引;若查询在一个月中只取某租户的少量事件,本地复合索引通常有价值。
父分区表不能直接使用 CREATE INDEX CONCURRENTLY。在线补索引的模式是:
CREATE INDEX event_log_type_time_idx
ON ONLY app.event_log (event_type, occurred_at);
CREATE INDEX CONCURRENTLY event_log_2026_06_type_time_idx
ON app.event_log_2026_06 (event_type, occurred_at);
ALTER INDEX app.event_log_type_time_idx
ATTACH PARTITION app.event_log_2026_06_type_time_idx;
对所有叶分区完成并 Attach 后,父级分区索引才会变为有效。此流程必须由迁移系统跟踪完整性,不能漏掉叶分区。
5.10 Unique Constraint、Primary Key 与全局唯一业务 ID
PostgreSQL 18 在分区父表上建立 UNIQUE 或 PRIMARY KEY 时:
- 唯一键必须包含所有分区键列;
- 分区键不能包含表达式或函数调用;
- 多级分区时,约束需覆盖目标父表及后代分区层级所需的全部分区键。
原因是每个真实索引只属于一个叶分区,核心 PostgreSQL 没有一个通用索引跨所有叶分区仲裁冲突。
如何保证全局唯一业务 ID
| 方案 | 数据库保证强度 | 优点 | 代价与适用条件 |
|---|---|---|---|
将分区键纳入业务主键,如 (occurred_at, event_id) | 强 | 原生约束,简单 | 调用方必须携带复合键;单独 event_id 不由数据库全局验证 |
| 按业务 ID Hash 分区,并以业务 ID 为唯一键 | 强 | UNIQUE(id) 包含分区键,可全局强制 | 不利于按时间整分区淘汰;时间查询跨多个 Hash 分区 |
独立未分区注册表 event_identity(id PK, occurred_at),同一事务先注册再写事件 | 强 | 保留时间 Range 生命周期与全局唯一性 | 多写一张表和索引;注册表可能成为容量/写热点;删除与归档要设计一致性 |
| 应用生成 UUIDv7/随机 UUID,并接受碰撞概率模型 | 概率性 | 无中心写热点,跨系统方便;[PG18] 可用 uuidv7() | 不是数据库跨分区唯一证明;安全/审计域可能仍要求注册表 |
| 触发器查询所有分区查重 | 弱且危险 | 表面上像全局检查 | 并发竞态、扫描成本和锁复杂,通常禁止 |
强一致注册表示例:
CREATE TABLE app.event_identity (
event_id uuid PRIMARY KEY,
occurred_at timestamptz NOT NULL,
tenant_id bigint NOT NULL
);
BEGIN;
INSERT INTO app.event_identity (event_id, occurred_at, tenant_id)
VALUES ($1, $2, $3);
INSERT INTO app.event_log (
tenant_id, event_id, occurred_at, event_type, payload
) VALUES ($3, $1, $2, $4, $5);
COMMIT;
注册表也需要生命周期策略。若事件删除后 ID 永远不可复用,则注册表可能长期增长;可只保留哈希指纹、使用另一种分片方式,或明确 ID 可复用窗口。
5.11 Foreign Key
PostgreSQL 18 支持分区表参与外键,但要先满足引用侧唯一性:被引用列必须由有效的主键或唯一约束保证。因此,若被引用表自身按时间分区,单列业务 ID 无法在父表上建立唯一约束,就不能直接作为标准 FK 的全局目标。
生产注意事项:
- 外键检查可能访问多个叶索引;让引用列与分区键对齐可减少放大。
ON DELETE CASCADE作用于大量分区行时仍是逐行 DML,不会自动变成DROP PARTITION。DETACH会考虑引用该分区表的外键并获取相关锁;执行前检查锁图。- [PG15+] 改进了分区键更新导致行跨分区移动时的外键语义,根表层面表现更接近一次更新。
5.12 ATTACH、DETACH、DETACH CONCURRENTLY 与 DROP
ATTACH:先离线准备,再短时纳入
CREATE TABLE app.event_log_2026_08_stage
(LIKE app.event_log INCLUDING DEFAULTS INCLUDING CONSTRAINTS);
ALTER TABLE app.event_log_2026_08_stage
ADD CONSTRAINT event_log_2026_08_bound
CHECK (
occurred_at >= TIMESTAMPTZ '2026-08-01 00:00:00+00'
AND occurred_at < TIMESTAMPTZ '2026-09-01 00:00:00+00'
);
-- 可先装载、校验、建索引,再 Attach。
ALTER TABLE app.event_log
ATTACH PARTITION app.event_log_2026_08_stage
FOR VALUES FROM ('2026-08-01 00:00:00+00')
TO ('2026-09-01 00:00:00+00');
匹配且已验证的 CHECK 约束可避免 Attach 时扫描待附加表。若存在 Default,还应先让 Default 具有可证明排除新范围的约束,否则会扫描并 ACCESS EXCLUSIVE 锁住 Default。
普通 DETACH
ALTER TABLE app.event_log
DETACH PARTITION app.event_log_2025_06;
分区保留为独立表,父表通常需要 ACCESS EXCLUSIVE 锁。适合维护窗口或已停止访问的树。
DETACH CONCURRENTLY [PG14+]
ALTER TABLE app.event_log
DETACH PARTITION app.event_log_2025_06 CONCURRENTLY;
- 不能放在事务块中。
- 父表存在 Default 时不允许。
- 内部使用两个事务:第一阶段以
SHARE UPDATE EXCLUSIVE标记待分离并提交,然后等待所有旧事务结束;第二阶段再次锁父表,并以ACCESS EXCLUSIVE锁叶分区完成分离和补充CHECK。 - 中断后使用:
ALTER TABLE app.event_log
DETACH PARTITION app.event_log_2025_06 FINALIZE;
同一父表同一时间最多一个待完成的并发分离。
DROP
DROP TABLE app.event_log_2025_06;
它会快速删除整分区,不逐行产生每条记录的删除版本,也无需后续 Vacuum 回收这些行。但它需要父表 ACCESS EXCLUSIVE 锁,且删除不可通过普通 SQL 查询恢复;必须确认备份、归档和保留策略。
5.13 锁行为速查
| 操作 | 父表主要锁 | 叶表/待附加表主要锁 | 关键并发风险 |
|---|---|---|---|
CREATE TABLE ... PARTITION OF | ACCESS EXCLUSIVE | 新表创建锁 | 阻塞父表读写,适合预建空分区且设置短 lock_timeout |
ATTACH PARTITION | SHARE UPDATE EXCLUSIVE | 待附加表 ACCESS EXCLUSIVE;Default 也可能 ACCESS EXCLUSIVE | 验证扫描时间长、Default 被读时等待 |
普通 DETACH PARTITION | 通常 ACCESS EXCLUSIVE | 相应强锁 | 读写父表均可能阻塞 |
DETACH ... CONCURRENTLY | 两阶段 SHARE UPDATE EXCLUSIVE | 第一阶段较弱,第二阶段 ACCESS EXCLUSIVE | 等待旧事务;不可在事务块;不可有 Default |
DROP TABLE 分区 | ACCESS EXCLUSIVE | 删除目标关系 | 短操作也可能因锁队列长时间等待 |
父表 CREATE INDEX | 递归建立叶索引 | 每个叶表相应锁 | 不支持父级 CONCURRENTLY,大树风险高 |
DDL 的“执行时间短”不代表“总延迟短”。在锁队列中排队 30 分钟后只执行 50 ms,仍是严重事故。始终设置合适的 lock_timeout,并在重试前检查 blocker,而不是无界重试。
5.14 Partition-Wise Join 与 Aggregate
SET LOCAL enable_partitionwise_join = on;
SET LOCAL enable_partitionwise_aggregate = on;
两项默认均为 off,因为计划节点数、规划 CPU、规划内存以及受 work_mem 限制的执行节点可能随扫描分区数线性增长。
Partition-Wise Join 典型条件:
- 两侧都按兼容键分区;
- Join 条件包含全部分区键,类型一致;
- 子分区能一一匹配。
SELECT o.order_month, sum(o.amount)
FROM app.orders o
JOIN app.refund r
ON r.order_month = o.order_month
AND r.order_id = o.order_id
WHERE o.order_month >= DATE '2026-01-01'
AND o.order_month < DATE '2026-07-01'
GROUP BY o.order_month;
Partition-Wise Aggregate 可在每个分区先聚合;若 GROUP BY 不包含全部分区键,还需最终聚合。它适合扫描少数大分区的分析型查询,不应因“听起来更快”而全局开启。
[PG18] 改进了访问大量分区时的规划效率,允许更多场景使用 Partition-Wise Join,并降低其内存用量,同时改善分区查询成本估算。但这些改进不是取消分区数量成本;不良谓词仍可留下大量分区,且各计划节点仍有 CPU/内存代价。
5.15 分区统计信息与 Autovacuum
- 每个叶分区是普通表,拥有自己的
pg_statistic、pg_stat_all_tables、Dead Tuple、Freeze Age 和 Autovacuum 状态。 - 父分区表还需要描述整个继承/分区树的统计信息,用于跨分区估算。
- 叶分区发生变化不会自动触发父表 Analyze;Autovacuum 不会因为子分区变化而自动为虚拟父表完成所需的全树统计。因此应定期:
ANALYZE app.event_log;
- 新建并批量装载分区后,应对叶分区
ANALYZE,再根据跨分区查询需要 Analyze 父表。 - 不要全局关闭 Autovacuum。可按叶分区数据变化率设置表级阈值,但必须保留防 XID Wraparound 的保护。
诊断:
SELECT
relid::regclass AS partition,
n_live_tup,
n_dead_tup,
last_autovacuum,
last_autoanalyze,
autovacuum_count,
autoanalyze_count
FROM pg_stat_all_tables
WHERE relid IN (
SELECT relid FROM pg_partition_tree('app.event_log') WHERE isleaf
)
ORDER BY partition::text;
5.16 冷热数据、Tablespace 与归档
可让新分区位于快速 Tablespace,历史分区迁移到容量型存储:
CREATE TABLE app.event_log_2026_09
PARTITION OF app.event_log
FOR VALUES FROM ('2026-09-01 00:00:00+00')
TO ('2026-10-01 00:00:00+00')
TABLESPACE ts_hot;
ALTER TABLE app.event_log_2025_01
SET TABLESPACE ts_cold;
但要理解:
SET TABLESPACE会搬动物理数据,需要锁、I/O、额外空间和 WAL;不是零成本元数据操作。- Tablespace 不是备份、复制或对象存储归档。所有 Tablespace 必须被备份方案、Replica 主机路径和故障切换节点正确提供。
- 冷分区仍在主库中时,跨历史查询仍占用主库 CPU/I/O。真正归档可先
DETACH,使用COPY/pg_dump导出并验证,再删除独立表。 - Foreign Table 可作为更冷的只读层,但会引入远端一致性、网络、Planner 估算与备份边界问题,本章不将其视为透明替代。
5.17 自动创建未来分区
维护任务应提前创建至少覆盖“当前写入窗口 + 时钟偏差 + 延迟数据窗口”的未来分区。例如按月表可保持未来 3~6 个月,具体取决于发布频率和最长延迟事件。
必须满足:
- 独立 CronJob、systemd timer 或运维任务运行,不在 HTTP/gRPC 普通请求内动态 DDL。
- 使用 advisory lock 保证单实例。
- 有
lock_timeout、statement_timeout和最大重试次数。 - 只从可信日期生成对象名;DDL 标识符不能用
$1参数代替,最好封装为数据库侧受控函数并由 Go 参数化调用。 - 创建后验证边界、索引、约束、Tablespace、Owner、Privileges 和统计信息。
- 对“未来可写天数不足”“Default 出现行”“维护任务连续失败”告警。
5.18 分区与分片的区别
| 维度 | 单实例分区 | 分片 Sharding |
|---|---|---|
| 数据位置 | 同一 PostgreSQL Cluster 内的多个关系 | 多个 PostgreSQL Cluster/节点 |
| 资源上限 | 仍受单节点 CPU、内存、WAL、存储和连接上限约束 | 可横向扩展,但有跨节点协调成本 |
| 事务 | 原生单库事务 | 跨分片事务通常更复杂或受限 |
| Join/聚合 | 单 Planner/Executor 可统一优化 | 常需路由、下推和结果合并 |
| 高可用 | 整个 Cluster 一套 HA | 每个分片都需 HA,另有路由/元数据 HA |
| 主要目标 | 生命周期、局部索引、裁剪、维护隔离 | 突破单节点容量与吞吐边界 |
把单表切成 1,000 个分区不会增加 CPU 核数、WAL 写带宽或主库数量。单节点已到资源极限时,应进入容量规划和分片设计,而不是继续增加分区数量。
5.19 七个必须明确回答的问题
- 千万行是否必然需要分区? 不必然。千万行、甚至更多行的窄表,在索引、缓存、VACUUM 和查询设计良好时完全可能无需分区。是否分区取决于维护窗口、数据保留、单索引尺寸、查询裁剪率、装载/删除模式和 SLO,而不是行数阈值。
- 分区能否替代索引? 不能。分区裁剪先缩小关系集合,本地索引再缩小每个关系中的行集合。
- 为什么不包含分区键会扫描大量分区? 因为边界只描述分区键范围;没有该条件,任何分区都可能命中,只能逐分区扫描或探测本地索引。
- 按天、月、年如何选择? 让粒度贴近最小常用删除/归档批次和典型查询窗口,同时控制总分区数与单分区索引大小。按天适合高写入量和短保留;按月是常见平衡;按年适合低数据量、低频维护,不适合每次只删除一个月。
- Default Partition 有什么风险? 掩盖缺失分区、无限增长、Attach 新分区时扫描和强锁、数据迁移复杂,并阻止
DETACH CONCURRENTLY。 - 为什么 DROP 分区通常比大量 DELETE 高效?
DROP主要删除关系元数据和物理文件,不逐行写删除版本、维护每个索引、生成相同比例 WAL 或等待 Vacuum 回收;代价是强 DDL 锁和粗粒度删除。 - 如何保证全局唯一业务 ID? 让唯一键包含全部分区键、按 ID Hash 分区,或使用独立全局注册表;仅依赖随机 UUID 是概率保证,不是 PostgreSQL 跨分区唯一约束。
5.20 PostgreSQL 14~18 关键差异
| 版本 | 与本章直接相关的变化 |
|---|---|
| [PG14+] | 大量分区上的 UPDATE/DELETE 规划开销降低,并支持更多执行期裁剪;引入 DETACH PARTITION ... CONCURRENTLY 与 FINALIZE。 |
| [PG15+] | 当大量分区中仅少数相关时继续改善规划时间;Default/List 分区被裁剪后可在更多场景避免排序;改善跨分区行移动的 FK 行为;支持对分区表执行 CLUSTER。 |
| [PG16+] | 缓存 Range/List 分区查找,改善路由/查找性能;VACUUM/ANALYZE 可用 BUFFER_USAGE_LIMIT 控制共享缓冲区占用。 |
| [PG17+] | 支持布尔分区键 IS [NOT] UNKNOWN 裁剪;改善分区表 LIMIT 优化;EXPLAIN (MEMORY) 可辅助观察规划内存。 |
| [PG18] | 改善访问大量分区时的规划效率;扩大 Partition-Wise Join 适用场景并降低内存;改善分区查询成本估算;AIO 可影响顺序扫描、Bitmap Scan 和 Vacuum 的 I/O 路径,但不能消除无效跨分区访问。 |
6. 底层原理
6.1 INSERT 元组路由时间线
以 INSERT INTO app.event_log ... 为例:
- Parse/Analyze:根表名称解析为分区父表 OID,检查列、类型和权限。
- Plan:生成
ModifyTable写入计划。目标逻辑上是根表,但 Executor 准备分区路由状态。 - 计算分区键:从输入 Slot 读取
occurred_at;若是表达式键则计算表达式。 - 查找边界:在当前分区描述符中定位匹配子分区;多级分区继续向下查找。
- 无目标处理:没有匹配叶分区时,若存在 Default 则进入 Default,否则语句失败。
- 打开叶关系:建立对应
ResultRelInfo,检查叶分区约束、RLS、触发器和索引。 - 写 Heap:在叶分区生成 Tuple Version;必要时写 TOAST。
- 维护本地索引:每个叶索引插入 Index Tuple,唯一冲突只在该索引的可见范围内仲裁。
- 记录 WAL:Heap、Index、Visibility Map 等变化按正常规则产生 WAL。
- 提交:WAL 持久性、同步复制等待和 Commit 结果不确定性与普通表相同。
[PG16+] 对 Range/List 分区查找增加缓存优化,但这不意味着任意深度和数量的分区没有路由成本。
6.2 Partition Pruning 的证明问题
裁剪本质是集合不相交证明:
- 分区边界:
P = [2026-06-01, 2026-07-01) - 查询条件:
Q = [2026-08-01, 2026-09-01) - 若 Planner 能证明
P ∩ Q = ∅,该分区可被排除。
索引不参与这个证明。即使六月分区有 event_id 索引,查询 event_id = X 也与 occurred_at 边界没有逻辑矛盾,因此不能裁剪六月。
常见破坏证明的写法:隐式类型转换、非 Immutable 函数、复杂 OR、分区键上函数、时区不一致、与另一列比较。应先检查实际表达式和类型,而不是只看 SQL 文本是否“出现了时间”。
6.3 参数化查询的三种计划路径
- Custom Plan:每次根据具体参数规划,参数值可用于规划期裁剪;规划成本更高。
- Generic Plan + 初始化期裁剪:计划结构可包含多个子计划,执行初始化时用
$1/$2删除无关子计划;节省重复规划。 - 动态参数化计划:Nested Loop 内侧的分区键值来自外侧行,每次参数变化时重新裁剪;计划中可能列出多个子计划,但很多显示
(never executed)。
因此,看到 Generic Plan 不应直接判断“所有分区都会扫描”。必须查看 Subplans Removed、实际 loops、Buffers 和每个子计划的执行状态。
6.4 UPDATE 跨分区移动
当 occurred_at 从六月改到七月:
六月叶分区旧 Tuple --标记删除/更新--> 旧版本留给 MVCC
|
+--> 七月叶分区插入新 Tuple
+--> 七月本地索引插入
影响:
- 旧分区出现 Dead Tuple,需 Vacuum。
- 新分区产生新行和索引项。
- 两边都产生 WAL 和 Buffer Dirty。
- 并发事务可能看到符合其 Snapshot 的旧版本或新版本,而不会看到违反 MVCC 的“半行”。
- 若目标分区不存在,整个语句失败。
- 触发器、外键、逻辑复制和 CDC 消费者需按根表语义验证,不要假设只有单关系原地更新。
6.5 ATTACH 的验证与锁
ATTACH 必须确认:
- 列名、类型、顺序与父表一致;
NOT NULL、CHECK、父级索引/约束满足要求;- 所有现有行均位于新边界;
- 新边界与其他分区不重叠;
- Default 中不存在应迁入新分区的行。
若待附加表有匹配且有效的 CHECK,Planner/DDL 逻辑可跳过全表验证扫描。否则会在持有待附加表 ACCESS EXCLUSIVE 时扫描。Default 同理。优化 Attach 的关键不是“让 ALTER 更快”,而是在不影响父表的准备阶段先完成可证明约束与数据校验。
6.6 DETACH CONCURRENTLY 的状态机

第一阶段提交后,旧事务仍可能基于旧分区树运行,因此系统必须等待它们退出。长事务会直接拉长维护时间。并发版本降低的是父表锁强度,不是取消所有等待。
6.7 DROP 与 DELETE 的物理差异
DELETE WHERE occurred_at < cutoff:
- 逐行查找、加行锁、写 Heap 删除标记;
- 逐个维护索引可见性;
- 产生大量 WAL、Dirty Buffer 和可能的 Full-Page Image;
- 空间通常不会立即返还操作系统;
- 后续 Vacuum 清理 Dead Tuple 和索引垃圾;
- 长 Snapshot 会延迟清理。
DROP TABLE old_partition:
- 以关系为单位修改系统目录并安排删除物理文件;
- 不逐行制造 Dead Tuple;
- 不需要为这些行运行 Vacuum;
- 通常耗时和 WAL 不随行数线性增长;
- 但需要强 DDL 锁,删除粒度必须与分区边界一致。
6.8 Partition-Wise Join/Aggregate 的代价模型
传统计划可能先 Append 两侧分区,再进行一个大 Join。Partition-Wise Join 则构造多个“分区 A_i Join 分区 B_i”的子树。好处是局部数据集更小、缓存局部性更好、可并行;代价是计划节点数量增多,每个 Hash/Sort/Aggregate 节点都可能消耗 work_mem,规划搜索空间和内存也增长。
[PG18] 降低了其中部分规划和内存成本,并放宽了可用场景,但仍需以实际 EXPLAIN (ANALYZE, BUFFERS, SETTINGS, MEMORY)、进程 RSS、临时文件和 P95/P99 验证。
7. 内部数据结构和状态
7.1 关系与存储
| 对象 | pg_class.relkind | 是否有 Heap | 主要内容 |
|---|---|---|---|
| 分区父表/中间分区表 | p | 否 | 列定义、分区策略、约束、虚拟索引关系 |
| 叶普通表 | r | 是 | Heap Page、Tuple、FSM、VM、TOAST(如需要) |
| 父分区索引 | I | 否 | 分区索引层级和有效性 |
| 叶索引 | i | 是 | 真实 Index Page 与 Index Tuple |
父表没有 Heap Page,因此 pg_relation_size(parent) 不代表整棵树大小。使用:
SELECT pg_size_pretty(sum(pg_total_relation_size(relid))) AS total_tree_size
FROM pg_partition_tree('app.event_log')
WHERE isleaf;
注意 pg_total_relation_size 包含索引和 TOAST;按树求和时只统计叶,避免重复理解父虚拟对象。
7.2 系统目录
pg_partitioned_table
记录分区策略:
partstrat:r/l/h;partnatts:分区键数量;partdefid:Default 分区 OID;partattrs:键列号,0 表示表达式;partclass、partcollation、partexprs:操作符类、排序规则和表达式树。
pg_inherits
每个直接父子关系一行:
inhrelid:子关系;inhparent:直接父关系;inhseqno:继承顺序;inhdetachpending:是否处于并发 Detach 待完成状态。
pg_class
relispartition:是否为某父关系的分区;relpartbound:内部边界表达式;reltuples、relpages:估算与成本相关;relfrozenxid/relminmxid:叶表防 Wraparound 状态。
可读化边界:
SELECT
c.oid::regclass AS relation,
c.relkind,
c.relispartition,
pg_get_expr(c.relpartbound, c.oid) AS partition_bound,
i.inhdetachpending
FROM pg_class AS c
LEFT JOIN pg_inherits AS i
ON i.inhrelid = c.oid
WHERE c.oid IN (
SELECT relid FROM pg_partition_tree('app.event_log')
)
ORDER BY c.oid::regclass::text;
7.3 Relcache、Memory Context 与缓存计划
每个访问分区树的 Backend 会把相关关系、属性、分区描述和边界元数据装入本地内存。会话长期访问大量分区时,进程内存可随已触碰关系增长。DDL 改变树后,通过 Catalog Invalidation 使其他 Backend 的 Relcache 和缓存计划重新验证。
因此:
- 过度分区不仅是一次查询的 Planning Time 问题,也是大量长连接的累计内存问题。
- PgBouncer 降低空闲 Backend 数不等于消除每个活跃 Backend 的分区元数据成本。
- 频繁创建/删除分区会使缓存计划反复失效;维护频率应与粒度收益平衡。
7.4 Lock 状态
查询父表通常会对实际访问或计划涉及的关系获取关系锁。初始化期裁剪发生前,某些后来被裁剪的分区仍可能在执行开始时锁定。锁诊断不能只看“实际扫描了几张表”,还应看 pg_locks 中父表和叶分区的 Relation Lock。
SELECT
a.pid,
a.xact_start,
a.query_start,
a.state,
a.wait_event_type,
a.wait_event,
l.mode,
l.granted,
l.relation::regclass AS relation,
a.query
FROM pg_locks AS l
JOIN pg_stat_activity AS a USING (pid)
WHERE l.relation IN (
SELECT relid FROM pg_partition_tree('app.event_log')
)
ORDER BY l.granted, a.query_start;
7.5 WAL、LSN 与 Replica
分区 DML 的 WAL 与普通叶表 DML相同;分区 DDL 还会记录目录与关系创建/删除相关变化。物理 Standby 重放后必须得到一致的分区树和文件状态。逻辑复制按发布配置和版本行为处理分区根/叶,切换策略必须在第 22 章单独验证;不要假设订阅端自动拥有相同分区 DDL。
监测维护前后 WAL:
SELECT pg_current_wal_lsn();
-- 执行实验操作
SELECT pg_size_pretty(pg_wal_lsn_diff(pg_current_wal_lsn(), $1::pg_lsn));
7.6 统计状态
- 叶级统计决定单分区扫描、索引和 Join 估算。
- 父级继承统计帮助跨分区查询。
- 新分区初始
reltuples可能为未知/低质量值;批量装载后不 Analyze 会导致估算错误。 - 分区数据分布差异很大时,逐叶统计反而是优势;但父级合并估算仍可能掩盖热点月份,需要对关键 SQL 做参数分布测试。
8. 场景和选型决策
| 业务场景 | 推荐方案 | 不推荐方案 | 原因 | 性能代价 | 并发代价 | 一致性代价 | 高可用代价 | 运维复杂度 |
|---|---|---|---|---|---|---|---|---|
| 日志/事件,按月保留 24 个月 | RANGE(occurred_at),月分区,预建未来分区 | 单表每日大批 DELETE | 生命周期边界与删除批次一致 | 跨月查询有 Append;单月索引更小 | 月度 DDL 需锁管理 | 无额外业务一致性代价 | DDL/WAL 要在副本重放;备份需覆盖树 | 中 |
| 每日 TB 级、只保留 14 天 | 日分区,必要时日内 Hash 子分区 | 月分区 | 单月过大、删除粒度太粗 | 分区数约 14~几十,可控 | 每日维护频繁 | 分区键应不可变 | 更频繁 DDL 需监控 Replica Lag | 中高 |
| 低写入审计表,保留 10 年,按年查询 | 年或季度 Range | 每日分区 | 每日分区收益小、对象数过多 | 年内索引较大 | DDL 少 | 简单 | 备份对象少 | 低 |
| 少量固定区域隔离 | LIST(region) | 每租户一分区 | 区域低基数、边界稳定 | 跨区域查询多个分区 | 区域迁移可能跨分区 UPDATE | 主键需包含 region | Failover 无特殊变化 | 低中 |
| 幂等键高并发点查,需数据库全局唯一 | HASH(idempotency_key) + PK(key) | 时间 Range + 仅叶级 UNIQUE(key) | Hash 键即唯一键,可原生强制 | 时间清理触及全部 Hash 分区 | 写热点较均匀,但仍共享 WAL | 强唯一 | 单节点资源上限不变 | 中 |
| 事件按时间淘汰,同时需全局 ID | 时间 Range + 独立 ID 注册表 | 触发器扫描全树查重 | 分离生命周期和唯一仲裁 | 每写多一次索引/表访问 | 注册表可能热点 | 强唯一,可事务化 | 注册表纳入备份/复制 | 高 |
| 典型查询不带候选分区键 | 重新选键、增加路由元数据或不分区 | 仅靠大量本地索引 | 无法裁剪会读放大 | Planning/Index Probe 放大 | 关系锁和连接占用上升 | 无 | 故障时恢复慢查询更多 | 中高 |
| 热数据 NVMe、冷数据容量盘 | 按时间分区 + Tablespace/Detach 归档 | 期望 OS 自动识别业务冷热 | 分区提供明确移动单元 | 移动产生 I/O/WAL | SET TABLESPACE 锁与带宽竞争 | 无 | 每个 HA 节点需相同存储布局 | 高 |
| 单节点 CPU/WAL 已饱和 | 进入读扩展/分片设计 | 继续增加分区数 | 分区不增加硬件资源 | 不能解决总吞吐上限 | 不减少全局 WAL/连接争用 | 分片会引入新一致性问题 | 每分片独立 HA | 很高 |
8.1 按天、月、年选择的计算框架
不要以“行数”单指标拍板。至少收集:
- 每日新增行数与平均/尾部行宽;
- 每日 Heap、TOAST、各索引增长量;
- 典型查询时间窗:1 小时、1 天、30 天还是全历史;
- 最小删除与归档批次;
- 允许的 DDL 频率和维护窗口;
- 保留年限对应的总分区数;
- 每个查询裁剪后剩余分区数;
- 单分区 Vacuum、Analyze、备份、恢复和索引重建时限。
经验不是固定规则:让单分区足够小,以便在目标维护窗口内完成;又足够大,避免分区对象数、规划和 DDL 频率主导系统成本。
9. 高性能分析
9.1 评估前必须记录的环境
任何分区基准必须记录:PostgreSQL 版本和配置、数据量、平均/P95 行宽、分区数量与层级、每分区索引、数据倾斜、并发数、读写比例、测试持续时间、连接池大小、CPU、内存、shared_buffers、存储介质、文件系统、缓存冷热状态、查询窗口和 SLO。没有这些信息的“月分区比日分区快 30%”没有可迁移性。
9.2 CPU
收益:裁剪可减少 Filter、Tuple 解码、表达式计算和索引比较。风险:大量分区增加 Planner CPU、Relcache 查找、计划节点初始化和多个本地索引探测。PG18 改善了大量分区规划效率,但不能补救没有裁剪条件的查询。
关键指标:EXPLAIN 的 Planning/Execution Time、pg_stat_statements.total_plan_time/mean_plan_time、数据库进程 CPU、计划节点数量、每次执行剩余分区数。
9.3 内存
- Planner/Executor 为每个剩余分区和计划节点分配状态。
- Partition-Wise Join/Aggregate 可产生多个受
work_mem限制的节点,总内存不是“整条查询只用一个 work_mem”。 - 每个 Backend 会缓存访问过的分区元数据。
- 并发 100 条跨 500 分区查询与单条查询完全不同。
[PG17+] 可结合 EXPLAIN (ANALYZE, MEMORY, ...) 观察规划期内存;仍需监测 OS RSS、OOM 和 cgroup 限制。
9.4 shared_buffers 与 OS Page Cache
较小的热分区和本地索引可能提高缓存局部性;但跨全历史扫描会逐个污染 Buffer Cache 和 OS Page Cache。按分区拆表不会自动为热数据预留缓存。需要通过访问模式、索引、Tablespace、预热策略和工作负载隔离控制。
9.5 随机 I/O、顺序 I/O 与 PG18 AIO
- 单点查若没有分区键,可能在许多叶索引做随机 I/O。
- 宽时间窗聚合可能对多个叶表做顺序扫描。
- [PG18] AIO 可让部分顺序扫描、Bitmap Heap Scan 和 Vacuum 排队更多读取,提升 I/O 并行度;应监测
pg_stat_io、设备队列、吞吐和延迟。 - AIO 不改变逻辑读放大:扫描 120 个无关分区仍是错误设计,只是 I/O 提交机制更高效。
9.6 网络往返
分区在服务器内部透明,单条跨分区 SQL不等于 100 次客户端往返。但应用逐分区循环查询会制造 N 次网络往返、N 次规划/执行和连接占用,应改为一条根表查询,让 PostgreSQL裁剪和并行。
9.7 索引维护成本
每个新分区都需要完整索引集合;索引越多,未来分区创建时间、Catalog 对象数、写放大和备份体积越大。冷热分区可采用不同额外索引,但父级索引会要求整个树保持匹配。历史只读分区可考虑删除不再需要的写路径索引,前提是查询与恢复演练验证。
9.8 WAL 与 Checkpoint
- 批量
DELETE产生与行数和索引维护相关的大量 WAL,推高 Checkpoint、归档和 Replica Lag。 DROP/DETACH的 WAL 远小于逐行删除,但仍是 DDL,需在副本重放目录变更。- 新分区批量装载可能造成短期 WAL 峰值;使用
COPY、合理批次、Checkpoint 和复制监控,而不是关闭fsync或full_page_writes。
9.9 Vacuum
分区允许逐叶 Vacuum 和 Freeze,缩小单次工作集;但叶表数量多会增加 Autovacuum 调度和启动成本。历史只读分区仍需 Freeze 到安全年龄,不能因“不会更新”就永远忽略。父表统计则需手工 Analyze。
9.10 Temporary File
跨分区 Join/Aggregate/Sort 可能产生一个大临时文件,也可能因 Partition-Wise 计划产生多个局部临时文件。监测 log_temp_files、pg_stat_database.temp_bytes、EXPLAIN 的 Sort Method/Hash Batches。不要只提高 work_mem;并发乘法可能造成内存事故。
9.11 吞吐量与 P95/P99
平均延迟常掩盖两个尾延迟来源:
- 缺失分区键的少数跨全树查询;
- 分区 DDL 在锁队列中阻塞大量后续请求。
基准应分别测:单分区、典型跨 3 分区、最坏全历史;冷/热缓存;Custom/Generic Plan;无 DDL与维护并发;并记录 P50/P95/P99、TPS、Planning Time、Buffers、WAL、CPU、I/O 和 Wait Event。
9.12 读、写和空间放大
- 读放大:剩余分区数 × 每分区扫描/索引探测。
- 写放大:Heap + 每个叶索引 + WAL;跨分区 UPDATE 近似两侧写入。
- 空间放大:每叶表和索引的固定元数据/Page 开销、重复索引根页、Bloat、Default 临时重复、归档副本。
10. 高并发分析
10.1 必须区分的并发量
- 应用 goroutine 数:可能远大于连接数,只代表潜在请求。
- 连接数:PostgreSQL Backend 或池连接数量。
- 活跃查询数:当前正在 CPU/I/O/锁等待的查询。
- TPS:单位时间成功事务数。
- 排队请求数:在应用 semaphore、pgxpool Acquire 或数据库锁队列中等待的请求。
分区不会自动解决连接竞争。一个请求扫描 500 分区会更久占用连接,使池 Acquire Wait 和排队请求增加。
10.2 MVCC 与长事务
每个叶分区仍遵循 MVCC。长事务可能:
- 阻止旧分区 Dead Tuple 清理;
- 延迟
DETACH CONCURRENTLY等待旧事务阶段; - 在强 DDL 已排队时造成后续请求排队;
- 让历史分区删除后的空间回收与 Replica 冲突更复杂。
监控 xact_start、backend_xmin、最老 Snapshot 和 idle in transaction。
10.3 锁竞争与阻塞队列
典型事故:维护任务请求 ACCESS EXCLUSIVE,被一个长查询阻塞;强锁进入队列后,后续本可与长查询兼容的请求也可能排在 DDL 后面,形成“长查询 + DDL + 请求雪崩”。
维护任务应:
SET lock_timeout = '2s';
SET statement_timeout = '30s';
超时后先诊断 blocker,再有界重试。不能持续立即重试,否则形成 DDL 重试风暴。
10.4 热点行与热点索引页
时间 Range 分区可把“当前月”写入集中到一个叶表,但并不消除热点:
- 单调主键可能集中到 B-tree 右端页;
- 所有写仍争用同一 WAL、Buffer Mapping、Checkpoint 和存储;
- 单租户计数器仍是单行锁热点。
二级 Hash 子分区可分散部分本地索引和 Heap 写入,但会增加分区数,且不能解决单行更新、全局序列或主库 WAL 上限。
10.5 死锁
跨分区更新、外键和业务多行修改可能以不同顺序锁定多个叶分区中的行,仍会死锁。统一按稳定业务键排序更新,缩短事务,并仅对 SQLSTATE 40P01 重试完整事务。不要将 DDL 锁超时 55P03 与死锁混为一谈。
10.6 Backpressure 与 Admission Control
- 普通请求使用有界 pgxpool;不要为每个 goroutine 创建连接。
- 全历史报表、分区维护和在线业务使用不同队列/连接预算。
- 对跨分区查询设置业务允许的最大时间窗,或路由到只读副本/分析系统。
- 维护任务单实例、低并发;创建 24 个分区不需要 24 个 goroutine 并发 DDL。
- 监控
pgxpool.Stat()的AcquiredConns、EmptyAcquireCount、EmptyAcquireWaitTime和取消次数。
10.7 事务边界、幂等与 Commit 不确定
未来分区创建使用事务性 DDL,但连接在 COMMIT 后断开时,客户端不能武断认定“未创建”。重试必须先查询目录判断目标分区及边界是否已存在。DDL 名称和边界是幂等键;CREATE TABLE IF NOT EXISTS 只检查名称存在,不保证现有对象定义正确,因此仍需后置验证。
10.8 goroutine 与连接池
普通请求路径动态 DDL 是反模式:首次遇到新月份的许多 goroutine 可能同时请求强锁、占满连接池并重试。应由独立维护任务提前创建;请求路径若发现无分区,快速失败并告警,而不是自愈式建表。
11. 高可用分析
分区对高可用的关系主要是间接但重要:它不提供副本或故障转移,却改变 WAL、备份对象、恢复验证、维护锁和切换后的 Schema 一致性。
11.1 RPO 与 RTO
- RPO 由同步/异步复制、WAL 归档与备份策略决定,不因分区自动改善。
- 分区可缩短历史数据删除、局部恢复演练或索引重建的维护时间,从而间接改善运维 RTO。
- 误
DROP分区是逻辑删除;恢复 RPO/RTO 取决于 PITR 或独立归档,不存在“分区回收站”。
11.2 备份与 PITR
- 物理备份必须包含所有 Tablespace、叶分区文件和系统目录。
- PITR 恢复到
DROP前可找回分区,但通常需要恢复到隔离实例再导出目标数据,不能随意让生产主库整体回退。 DETACH后导出冷数据时,要校验行数、边界、校验和/对象清单,并演练恢复,而不是“文件上传成功即归档完成”。
11.3 物理复制与复制延迟
- 大量
DELETE的 WAL 和后续 Vacuum 会加重网络、归档和 Standby Replay;整分区淘汰通常更友好。 CREATE INDEX、Tablespace 搬迁和批量装载仍可能造成巨大 WAL 峰值。- DDL 在 Standby 重放时可能与长查询产生 Recovery Conflict;历史报表副本也需控制长事务。
11.4 逻辑复制
逻辑复制的分区根发布、叶表身份、Replica Identity 与 DDL 同步需按第 22 章的具体拓扑验证。核心逻辑复制不会替你把订阅端 Schema 维护成完全相同的分区树。发布新分区前,要确认订阅端表、约束、权限和路由策略已就绪。
11.5 Planned Switchover / Unplanned Failover
切换前后必须验证:
- 未来分区在候选新主库上已通过复制存在;
- 所有 Tablespace 路径与挂载可用;
- 维护调度器只有一个 Active 实例,避免旧主与新主同时执行 DDL;
- 旧连接被关闭或重新建立,不能继续向旧主写入;
- 若 Commit 结果不确定,维护任务通过目录复核后再重试。
11.6 脑裂与 Fencing
若两个节点都接受写入,它们可能各自创建不同分区或向同名分区写入不同数据;后续不能靠简单 Attach 合并。分区不改变脑裂原则:必须先 Fencing 旧主,再提升新主,维护任务也应受同一领导者租约或调度控制。
11.7 数据恢复验证
恢复后至少检查:
SELECT * FROM pg_partition_tree('app.event_log');
SELECT
c.oid::regclass,
pg_get_expr(c.relpartbound, c.oid)
FROM pg_class AS c
WHERE c.oid IN (
SELECT relid FROM pg_partition_tree('app.event_log')
)
ORDER BY 1;
并抽样验证每个边界的最小/最大时间、父级索引有效性、Default 行数、未来分区覆盖和关键查询裁剪。
12. 三维影响矩阵
| 维度 | 相关度 | 核心收益 | 主要风险 | 关键指标 |
|---|---|---|---|---|
| 高性能 | 高 | 裁剪无关数据、缩小本地索引、改善缓存局部性、快速淘汰历史 | 过度分区、规划/内存放大、跨分区查询、重复索引 | Planning Time、剩余分区数、Buffers、P95/P99、temp bytes、CPU/I/O |
| 高并发 | 中高 | 将部分维护和局部写入隔离到叶分区 | DDL 强锁、长事务、锁队列、当前分区热点、连接占用 | Lock Wait、blocker 年龄、Active Queries、pool wait、WAL throughput |
| 高可用 | 中 | 降低逐行删除 WAL、提供归档单元、缩短部分维护窗口 | 误删恢复、Tablespace 一致性、Replica Lag、双主维护任务 | WAL/Replay Lag、归档验证、未来分区覆盖、恢复演练 RTO |
13. 实验
统一安全说明
以下实验只能在可丢弃数据库执行。实验包含真实
DELETE、DDL、锁等待和 WAL 生成。对 DML 使用EXPLAIN ANALYZE会真正执行语句;即使包在BEGIN/ROLLBACK中,Sequence、外部系统副作用及部分观测影响也不一定完全回滚。本章不关闭fsync、full_page_writes、Autovacuum 或数据校验。
13.1 实验一:按月事件表与 Partition Pruning
13.1.1 实验目标
- 建立六个月的 Range 分区表。
- 对比规划期裁剪、Generic Plan 的初始化期裁剪,以及关闭裁剪后的计划。
- 验证分区裁剪由时间边界驱动,本地索引不能弥补缺失分区键。
- 建立不伪造固定耗时的性能记录模板。
13.1.2 版本与扩展
- PostgreSQL 14~18 均可执行;输出以 PostgreSQL 18 为基线。
- 必要扩展:无。
- 可选扩展:
pg_stat_statements,仅在实验环境已按规范预加载时使用。
13.1.3 建表与准备数据
DROP SCHEMA IF EXISTS lab14 CASCADE;
CREATE SCHEMA lab14;
CREATE TABLE lab14.event_monthly (
tenant_id integer NOT NULL,
event_id bigint NOT NULL,
occurred_at timestamptz NOT NULL,
event_type text NOT NULL,
payload text NOT NULL,
PRIMARY KEY (occurred_at, event_id)
) PARTITION BY RANGE (occurred_at);
CREATE TABLE lab14.event_monthly_2026_01
PARTITION OF lab14.event_monthly
FOR VALUES FROM ('2026-01-01 00:00:00+00') TO ('2026-02-01 00:00:00+00');
CREATE TABLE lab14.event_monthly_2026_02
PARTITION OF lab14.event_monthly
FOR VALUES FROM ('2026-02-01 00:00:00+00') TO ('2026-03-01 00:00:00+00');
CREATE TABLE lab14.event_monthly_2026_03
PARTITION OF lab14.event_monthly
FOR VALUES FROM ('2026-03-01 00:00:00+00') TO ('2026-04-01 00:00:00+00');
CREATE TABLE lab14.event_monthly_2026_04
PARTITION OF lab14.event_monthly
FOR VALUES FROM ('2026-04-01 00:00:00+00') TO ('2026-05-01 00:00:00+00');
CREATE TABLE lab14.event_monthly_2026_05
PARTITION OF lab14.event_monthly
FOR VALUES FROM ('2026-05-01 00:00:00+00') TO ('2026-06-01 00:00:00+00');
CREATE TABLE lab14.event_monthly_2026_06
PARTITION OF lab14.event_monthly
FOR VALUES FROM ('2026-06-01 00:00:00+00') TO ('2026-07-01 00:00:00+00');
-- 每月 100,000 行,共 600,000 行。根据实验机器调整,但必须记录实际值。
INSERT INTO lab14.event_monthly (
tenant_id, event_id, occurred_at, event_type, payload
)
SELECT
1 + (g % 1000)::integer,
(m.month_no::bigint * 1000000) + g,
m.month_start + ((g % 2500000) * interval '1 second'),
CASE g % 4
WHEN 0 THEN 'created'
WHEN 1 THEN 'updated'
WHEN 2 THEN 'viewed'
ELSE 'deleted'
END,
repeat(md5(g::text), 4)
FROM (
VALUES
(1, TIMESTAMPTZ '2026-01-01 00:00:00+00'),
(2, TIMESTAMPTZ '2026-02-01 00:00:00+00'),
(3, TIMESTAMPTZ '2026-03-01 00:00:00+00'),
(4, TIMESTAMPTZ '2026-04-01 00:00:00+00'),
(5, TIMESTAMPTZ '2026-05-01 00:00:00+00'),
(6, TIMESTAMPTZ '2026-06-01 00:00:00+00')
) AS m(month_no, month_start)
CROSS JOIN generate_series(1, 100000) AS s(g);
CREATE INDEX event_monthly_tenant_time_idx
ON lab14.event_monthly (tenant_id, occurred_at);
ANALYZE lab14.event_monthly;
SELECT tableoid::regclass AS physical_partition, count(*)
FROM lab14.event_monthly
GROUP BY tableoid
ORDER BY physical_partition::text;
确认每个分区行数,并记录:
SELECT version();
SHOW shared_buffers;
SHOW effective_cache_size;
SHOW work_mem;
SHOW random_page_cost;
SHOW effective_io_concurrency;
SHOW io_method; -- [PG18];旧版本无此参数
SELECT
count(*) AS leaf_partitions,
pg_size_pretty(sum(pg_relation_size(relid))) AS heap_size,
pg_size_pretty(sum(pg_indexes_size(relid))) AS index_size
FROM pg_partition_tree('lab14.event_monthly')
WHERE isleaf;
13.1.4 Session A:常量查询,观察规划期裁剪
SET enable_partition_pruning = on;
EXPLAIN (
ANALYZE,
BUFFERS,
WAL,
SETTINGS,
VERBOSE,
SUMMARY
)
SELECT count(*)
FROM lab14.event_monthly
WHERE occurred_at >= TIMESTAMPTZ '2026-03-01 00:00:00+00'
AND occurred_at < TIMESTAMPTZ '2026-04-01 00:00:00+00'
AND tenant_id = 42;
预期:计划只出现 event_monthly_2026_03。是否使用 Seq Scan、Bitmap Scan 或 Index Scan 由选择率和成本决定;“只剩一个分区”不等于“一定使用索引”。
再执行不带分区键的点查:
EXPLAIN (
ANALYZE,
BUFFERS,
WAL,
SETTINGS,
VERBOSE,
SUMMARY
)
SELECT *
FROM lab14.event_monthly
WHERE event_id = 3000042;
预期:六个叶分区都可能出现在计划中。主键的首列是 occurred_at,该查询甚至未必能有效利用主键;即使额外创建每叶 event_id 索引,也仍需对六个本地索引分别探测。
13.1.5 Session A:Generic Plan,观察初始化期裁剪
SET plan_cache_mode = force_generic_plan;
PREPARE event_count(timestamptz, timestamptz, integer) AS
SELECT count(*)
FROM lab14.event_monthly
WHERE occurred_at >= $1
AND occurred_at < $2
AND tenant_id = $3;
EXPLAIN (
ANALYZE,
BUFFERS,
WAL,
SETTINGS,
VERBOSE,
SUMMARY
)
EXECUTE event_count(
TIMESTAMPTZ '2026-05-01 00:00:00+00',
TIMESTAMPTZ '2026-06-01 00:00:00+00',
42
);
预期:计划通常包含可运行时裁剪的 Append,并显示类似 Subplans Removed: 5;实际只有五月分区产生 Buffer 访问。具体节点文本可能随版本、统计和成本不同。
SELECT name, generic_plans, custom_plans, statement
FROM pg_prepared_statements
WHERE name = 'event_count';
13.1.6 Session B:关闭裁剪作为对照
在独立会话执行:
SET enable_partition_pruning = off;
EXPLAIN (
ANALYZE,
BUFFERS,
WAL,
SETTINGS,
VERBOSE,
SUMMARY
)
SELECT count(*)
FROM lab14.event_monthly
WHERE occurred_at >= TIMESTAMPTZ '2026-03-01 00:00:00+00'
AND occurred_at < TIMESTAMPTZ '2026-04-01 00:00:00+00'
AND tenant_id = 42;
预期:计划会保留所有分区,其他月份在叶节点执行 Filter 后返回零行。Buffers 和执行工作量上升。此设置只用于实验,生产不应为常规查询关闭裁剪。
13.1.7 Session C:观察活动、锁和等待
在 A/B 正在执行较大数据版本实验时:
SELECT
pid,
application_name,
state,
wait_event_type,
wait_event,
query_start,
clock_timestamp() - query_start AS query_age,
left(query, 160) AS query
FROM pg_stat_activity
WHERE datname = current_database()
AND pid <> pg_backend_pid()
ORDER BY query_start;
SELECT
l.pid,
l.mode,
l.granted,
l.relation::regclass AS relation
FROM pg_locks AS l
WHERE l.relation IN (
SELECT relid FROM pg_partition_tree('lab14.event_monthly')
)
ORDER BY l.pid, l.relation;
13.1.8 明确时间线
| 时间 | Session A | Session B | Session C | 结果 |
|---|---|---|---|---|
| T0 | 完成建表、装载、Analyze | — | — | 六个叶分区就绪 |
| T1 | 常量范围查询 | — | 查看计划/锁 | 规划期裁剪到一个分区 |
| T2 | Generic Plan + 参数 | — | 查看计划/锁 | 初始化期裁剪,检查 Subplans Removed |
| T3 | — | 关闭裁剪执行同查询 | 观察活动 | 所有叶分区保留 |
| T4 | 不带时间的 ID 查询 | — | — | 多分区访问,证明本地索引不等于裁剪 |
- 等待步骤:正常小数据下不应产生锁等待;扩大数据后可能出现 CPU/I/O 等待。
- 失败步骤:本实验无预期失败;若插入超出六月且无目标分区,会预期失败。
- 提交步骤:准备数据的
INSERT在 Autocommit 下提交;查询只读。
13.1.9 诊断字段解释
Planning Time:解析后生成计划的时间;分区数过多时可能显著增长。Execution Time:实际执行时间,不包含客户端传输全部结果的时间。Subplans Removed:初始化期裁剪掉的子计划数。loops:节点被执行次数;动态裁剪需结合它判断。never executed:该子计划每次都被运行期裁剪。Buffers shared hit/read:来自 shared_buffers 的命中/读取,不能直接等于物理磁盘 I/O。WAL:SELECT 通常无业务 WAL,但 Hint Bit 等行为和环境可能影响观测;以实际输出为准。SETTINGS:列出影响计划的非默认设置,便于复现实验。
13.1.10 性能记录表
不要填入预设数字;使用实际结果:
| 项目 | 裁剪开启/单月 | Generic + 初始化裁剪 | 裁剪关闭 | 不带分区键 |
|---|---|---|---|---|
| PostgreSQL 版本 | ||||
| 数据量/平均行宽 | ||||
| 冷/热缓存 | ||||
| 并发数/持续时间 | ||||
| P50/P95/P99 | ||||
| Planning Time | ||||
| Execution Time | ||||
| shared hit/read | ||||
| WAL bytes/records | ||||
| CPU | ||||
| I/O 吞吐/延迟 | ||||
| Wait Event | ||||
| 实际访问分区数 |
13.1.11 清理
DEALLOCATE event_count;
RESET plan_cache_mode;
RESET enable_partition_pruning;
-- 保留 lab14 给后续实验;全章结束后统一 DROP SCHEMA。
13.1.12 生产安全警告
- 不要通过全局关闭裁剪做长期对照。
- 冷缓存测试不要在共享生产主机上使用清理 OS Cache 的命令。
EXPLAIN ANALYZE会运行查询;对大范围查询可能影响生产缓存和 I/O。- 计划中分区节点文本会随版本和统计变化,判断依据应是实际访问、Buffers 和 loops,而不是死记某一行输出。
13.2 实验二:Default Partition 数据迁移到新分区
13.2.1 实验目标
- 稳定复现:Default 中存在目标范围数据时,Attach 正式分区先因锁等待,随后因数据冲突失败。
- 在明确阻断写入的事务中,把 Default 中数据迁入 Staging Table。
- 使用匹配
CHECK约束避免 Attach 对 Staging 和 Default 的重复验证扫描。 - 理解为什么 Default 必须监控为异常缓冲区。
13.2.2 版本与扩展
- PostgreSQL 14~18。
- 必要扩展:无。
13.2.3 建表和准备数据
DROP TABLE IF EXISTS lab14.event_with_default CASCADE;
CREATE TABLE lab14.event_with_default (
event_id bigint NOT NULL,
occurred_at timestamptz NOT NULL,
payload text NOT NULL,
PRIMARY KEY (occurred_at, event_id)
) PARTITION BY RANGE (occurred_at);
CREATE TABLE lab14.event_with_default_2026_01
PARTITION OF lab14.event_with_default
FOR VALUES FROM ('2026-01-01 00:00:00+00')
TO ('2026-02-01 00:00:00+00');
CREATE TABLE lab14.event_with_default_catchall
PARTITION OF lab14.event_with_default DEFAULT;
-- 二月正式分区尚未创建,因此这些行被路由到 Default。
INSERT INTO lab14.event_with_default (event_id, occurred_at, payload)
SELECT
g,
TIMESTAMPTZ '2026-02-01 00:00:00+00' + (g * interval '1 second'),
repeat(md5(g::text), 2)
FROM generate_series(1, 200000) AS s(g);
ANALYZE lab14.event_with_default_catchall;
SELECT tableoid::regclass AS physical_partition, count(*)
FROM lab14.event_with_default
GROUP BY tableoid;
-- 先准备一个空的二月 Staging Table,并给出可验证边界。
CREATE TABLE lab14.event_with_default_2026_02_stage
(LIKE lab14.event_with_default INCLUDING DEFAULTS INCLUDING CONSTRAINTS INCLUDING INDEXES);
ALTER TABLE lab14.event_with_default_2026_02_stage
ADD CONSTRAINT event_with_default_2026_02_stage_bound
CHECK (
occurred_at >= TIMESTAMPTZ '2026-02-01 00:00:00+00'
AND occurred_at < TIMESTAMPTZ '2026-03-01 00:00:00+00'
);
13.2.4 Session A:持有 Default 的 AccessShareLock
BEGIN;
SELECT count(*)
FROM lab14.event_with_default_catchall;
-- 不提交。SELECT 的 AccessShareLock 持续到事务结束。
SELECT pg_backend_pid() AS session_a_pid, now() AS snapshot_time;
13.2.5 Session B:尝试 Attach,先等待再失败
SET lock_timeout = '20s';
SET statement_timeout = '2min';
ALTER TABLE lab14.event_with_default
ATTACH PARTITION lab14.event_with_default_2026_02_stage
FOR VALUES FROM ('2026-02-01 00:00:00+00')
TO ('2026-03-01 00:00:00+00');
该命令需要对 Default 获取 ACCESS EXCLUSIVE。只要 Session A 未提交,它就等待。
13.2.6 Session C:诊断锁链
SELECT
blocked.pid AS blocked_pid,
blocker.pid AS blocker_pid,
blocked.wait_event_type,
blocked.wait_event,
clock_timestamp() - blocker.xact_start AS blocker_xact_age,
left(blocked.query, 120) AS blocked_query,
left(blocker.query, 120) AS blocker_query
FROM pg_stat_activity AS blocked
CROSS JOIN LATERAL unnest(pg_blocking_pids(blocked.pid)) AS b(blocker_pid)
JOIN pg_stat_activity AS blocker
ON blocker.pid = b.blocker_pid
WHERE blocked.wait_event_type = 'Lock';
SELECT
l.pid,
l.mode,
l.granted,
l.relation::regclass AS relation
FROM pg_locks AS l
WHERE l.relation IN (
'lab14.event_with_default'::regclass,
'lab14.event_with_default_catchall'::regclass,
'lab14.event_with_default_2026_02_stage'::regclass
)
ORDER BY l.granted, l.pid, relation::text;
重要字段:
pg_blocking_pids:数据库实际判定的直接 blocker PID。blocker_xact_age:优先处理最老事务,而不是只看最后一条 SQL。mode/granted:区分已持有锁与正在请求的锁。relation:确认阻塞发生在父表、Default 还是 Staging。
13.2.7 Session A:释放锁
COMMIT;
Session B 获得 Default 强锁后会扫描它,并预期失败,因为其中存在二月行。错误文本可能随版本略变,核心语义是 Default 中有行违反更新后的 Default 分区约束。
13.2.8 正确迁移流程
此实验使用显式
ACCESS EXCLUSIVE锁实现简单、可证明的停写窗口。生产可设计更复杂的双写或离线切换,但绝不能在迁移过程中允许新二月行继续进入 Default。
在 Session B 执行:
BEGIN;
SET LOCAL lock_timeout = '5s';
SET LOCAL statement_timeout = '5min';
-- 先锁父表,阻止通过父表的新读写;再锁 Default,防止直接叶表访问。
LOCK TABLE lab14.event_with_default
IN ACCESS EXCLUSIVE MODE;
LOCK TABLE lab14.event_with_default_catchall
IN ACCESS EXCLUSIVE MODE;
-- 原子地从 Default 删除并插入 Staging。
WITH moved AS (
DELETE FROM lab14.event_with_default_catchall
WHERE occurred_at >= TIMESTAMPTZ '2026-02-01 00:00:00+00'
AND occurred_at < TIMESTAMPTZ '2026-03-01 00:00:00+00'
RETURNING event_id, occurred_at, payload
)
INSERT INTO lab14.event_with_default_2026_02_stage (
event_id, occurred_at, payload
)
SELECT event_id, occurred_at, payload
FROM moved;
-- 让系统可证明 Default 不含二月范围;此处会验证现有 Default 数据。
ALTER TABLE lab14.event_with_default_catchall
ADD CONSTRAINT event_with_default_catchall_no_2026_02
CHECK (
occurred_at < TIMESTAMPTZ '2026-02-01 00:00:00+00'
OR occurred_at >= TIMESTAMPTZ '2026-03-01 00:00:00+00'
);
-- Staging 已有匹配有效 CHECK,Default 也有排除 CHECK,避免 Attach 扫描两张表。
ALTER TABLE lab14.event_with_default
ATTACH PARTITION lab14.event_with_default_2026_02_stage
FOR VALUES FROM ('2026-02-01 00:00:00+00')
TO ('2026-03-01 00:00:00+00');
-- Attach 后内部 Partition Bound 已负责约束,可移除临时证明约束。
ALTER TABLE lab14.event_with_default_2026_02_stage
DROP CONSTRAINT event_with_default_2026_02_stage_bound;
ALTER TABLE lab14.event_with_default_catchall
DROP CONSTRAINT event_with_default_catchall_no_2026_02;
COMMIT;
如果在获取锁时超过 lock_timeout,事务失败并回滚。不要无界重试;先找 blocker,确认能否安全终止或等待业务窗口。
13.2.9 验证结果
SELECT
tableoid::regclass AS physical_partition,
count(*) AS rows,
min(occurred_at) AS min_time,
max(occurred_at) AS max_time
FROM lab14.event_with_default
GROUP BY tableoid
ORDER BY physical_partition::text;
SELECT count(*) AS default_rows
FROM lab14.event_with_default_catchall;
SELECT
c.oid::regclass AS relation,
pg_get_expr(c.relpartbound, c.oid) AS bound
FROM pg_class AS c
WHERE c.oid IN (
SELECT relid FROM pg_partition_tree('lab14.event_with_default')
)
ORDER BY relation::text;
预期:
- 200,000 行位于新二月分区。
- Default 行数为 0。
- 父表查询可正常裁剪到二月分区。
EXPLAIN (ANALYZE, BUFFERS, WAL, SETTINGS, VERBOSE, SUMMARY)
SELECT count(*)
FROM lab14.event_with_default
WHERE occurred_at >= TIMESTAMPTZ '2026-02-01 00:00:00+00'
AND occurred_at < TIMESTAMPTZ '2026-03-01 00:00:00+00';
13.2.10 明确时间线
| 时间 | Session A | Session B | Session C | 等待/失败/提交 |
|---|---|---|---|---|
| T0 | BEGIN 并读 Default | — | — | A 持有 AccessShareLock |
| T1 | 保持事务 | 发起 Attach | 查询锁链 | B 等待 Default 的 AccessExclusiveLock |
| T2 | COMMIT | 获锁并扫描 Default | — | A 提交;B 因二月数据存在而失败 |
| T3 | — | 开始迁移事务并强锁父表/Default | 可观察业务请求等待 | B 建立受控停写窗口 |
| T4 | — | DELETE RETURNING → INSERT Staging | — | DML 尚未提交 |
| T5 | — | 加证明约束并 Attach | — | Attach 成功 |
| T6 | — | COMMIT | 验证 | 新二月分区上线 |
13.2.11 统计与性能记录
该实验不提供固定耗时。记录:
- Default 行数、大小、索引大小;
- 初次 Attach 的锁等待时长与扫描失败时长;
- 数据迁移的 Buffers、WAL、CPU、I/O;
- 强锁持有时间;
- 迁移期间业务 P95/P99 和 pgxpool 等待;
- Replica Lag;
- 添加匹配
CHECK前后 Attach 的扫描差异。
可单独对迁移 DML在可丢弃副本上执行:
EXPLAIN (ANALYZE, BUFFERS, WAL, SETTINGS, VERBOSE, SUMMARY)
WITH moved AS (
DELETE FROM lab14.event_with_default_catchall
WHERE occurred_at >= TIMESTAMPTZ '2026-02-01 00:00:00+00'
AND occurred_at < TIMESTAMPTZ '2026-03-01 00:00:00+00'
RETURNING event_id, occurred_at, payload
)
INSERT INTO lab14.event_with_default_2026_02_stage
SELECT * FROM moved;
警告:它会真实移动数据;只能在重置后的实验环境执行。
13.2.12 清理
本实验对象可留给观察;单独清理:
DROP TABLE IF EXISTS lab14.event_with_default CASCADE;
-- 如果 Attach 失败且 Staging 仍独立:
DROP TABLE IF EXISTS lab14.event_with_default_2026_02_stage;
13.2.13 生产安全警告
- 不要在未阻止新写入时“先搬一遍再 Attach”,否则会持续有新行落入 Default。
- 不要直接终止 blocker,先判断是否是关键事务、备份、迁移或复制维护。
- 强锁迁移适合明确维护窗口;在线迁移要设计双写/捕获增量、幂等和切换点。
- Default 行数告警应接近实时;等到月初才发现数亿行已经太晚。
13.3 实验三:DETACH/DROP 历史分区与大量 DELETE 比较
13.3.1 实验目标
- 在两棵相同分区树上分别执行大批
DELETE与DETACH CONCURRENTLY+DROP。 - 对比 WAL、Buffers、Dead Tuple、空间回收和锁等待。
- 复现长事务延迟并发 Detach 的等待阶段。
- 验证
DROP快不等于无锁,也不等于可随意生产执行。
13.3.2 版本与扩展
- PostgreSQL 14+ 才支持
DETACH PARTITION ... CONCURRENTLY。 - 必要扩展:无。
pg_stat_statements、OS I/O 工具为可选观测,不影响实验正确性。
13.3.3 建表和准备数据
DROP TABLE IF EXISTS lab14.events_delete CASCADE;
DROP TABLE IF EXISTS lab14.events_detach CASCADE;
CREATE TABLE lab14.events_delete (
event_id bigint NOT NULL,
occurred_at timestamptz NOT NULL,
payload text NOT NULL,
PRIMARY KEY (occurred_at, event_id)
) PARTITION BY RANGE (occurred_at);
CREATE TABLE lab14.events_detach
(LIKE lab14.events_delete
INCLUDING DEFAULTS
INCLUDING CONSTRAINTS
INCLUDING INDEXES)
PARTITION BY RANGE (occurred_at);
CREATE TABLE lab14.events_delete_2025_01
PARTITION OF lab14.events_delete
FOR VALUES FROM ('2025-01-01 00:00:00+00') TO ('2025-02-01 00:00:00+00');
CREATE TABLE lab14.events_delete_2025_02
PARTITION OF lab14.events_delete
FOR VALUES FROM ('2025-02-01 00:00:00+00') TO ('2025-03-01 00:00:00+00');
CREATE TABLE lab14.events_delete_2025_03
PARTITION OF lab14.events_delete
FOR VALUES FROM ('2025-03-01 00:00:00+00') TO ('2025-04-01 00:00:00+00');
CREATE TABLE lab14.events_detach_2025_01
PARTITION OF lab14.events_detach
FOR VALUES FROM ('2025-01-01 00:00:00+00') TO ('2025-02-01 00:00:00+00');
CREATE TABLE lab14.events_detach_2025_02
PARTITION OF lab14.events_detach
FOR VALUES FROM ('2025-02-01 00:00:00+00') TO ('2025-03-01 00:00:00+00');
CREATE TABLE lab14.events_detach_2025_03
PARTITION OF lab14.events_detach
FOR VALUES FROM ('2025-03-01 00:00:00+00') TO ('2025-04-01 00:00:00+00');
-- 每月 300,000 行。按实验环境调整,并记录实际数据和行宽。
INSERT INTO lab14.events_delete (event_id, occurred_at, payload)
SELECT
(m.month_no::bigint * 1000000) + g,
m.month_start + ((g % 2500000) * interval '1 second'),
repeat(md5(g::text), 6)
FROM (
VALUES
(1, TIMESTAMPTZ '2025-01-01 00:00:00+00'),
(2, TIMESTAMPTZ '2025-02-01 00:00:00+00'),
(3, TIMESTAMPTZ '2025-03-01 00:00:00+00')
) AS m(month_no, month_start)
CROSS JOIN generate_series(1, 300000) AS s(g);
INSERT INTO lab14.events_detach
SELECT * FROM lab14.events_delete;
ANALYZE lab14.events_delete;
ANALYZE lab14.events_detach;
SELECT
parent,
max(row_count) AS rows,
pg_size_pretty(sum(total_bytes)) AS total_size
FROM (
SELECT
'delete' AS parent,
(SELECT count(*) FROM lab14.events_delete) AS row_count,
pg_total_relation_size(relid) AS total_bytes
FROM pg_partition_tree('lab14.events_delete')
WHERE isleaf
UNION ALL
SELECT
'detach',
(SELECT count(*) FROM lab14.events_detach),
pg_total_relation_size(relid)
FROM pg_partition_tree('lab14.events_detach')
WHERE isleaf
) AS x(parent, row_count, total_bytes)
GROUP BY parent;
13.3.4 DELETE 路径:真实执行并记录 WAL
在 psql Session D 中开启计时:
\timing on
SELECT pg_current_wal_lsn() AS delete_lsn_before \gset
EXPLAIN (
ANALYZE,
BUFFERS,
WAL,
SETTINGS,
VERBOSE,
SUMMARY
)
DELETE FROM lab14.events_delete
WHERE occurred_at >= TIMESTAMPTZ '2025-01-01 00:00:00+00'
AND occurred_at < TIMESTAMPTZ '2025-02-01 00:00:00+00';
SELECT
pg_size_pretty(
pg_wal_lsn_diff(pg_current_wal_lsn(), :'delete_lsn_before'::pg_lsn)
) AS delete_wal_generated;
SELECT
relid::regclass AS relation,
n_live_tup,
n_dead_tup,
last_autovacuum,
vacuum_count,
autovacuum_count
FROM pg_stat_all_tables
WHERE relid = 'lab14.events_delete_2025_01'::regclass;
SELECT
pg_size_pretty(pg_relation_size('lab14.events_delete_2025_01')) AS heap_after_delete,
pg_size_pretty(pg_indexes_size('lab14.events_delete_2025_01')) AS indexes_after_delete;
预期:
DELETE节点显示大量实际行和 WAL records/bytes。n_dead_tup最终上升,但统计是近似且异步更新的,可运行ANALYZE后再观察。- 关系文件通常不会因普通 Delete 立即缩小。
- 后续
VACUUM可回收内部空间供复用,但通常不把全部文件空间返还 OS。
不要在同一测量前后恰好让 Autovacuum 介入而不记录。可观察但不要生产式禁用 Autovacuum;实验若需控制,应选择短窗口并记录发生的 Autovacuum。
13.3.5 Session A:建立长事务,使用待 Detach 分区
BEGIN;
SELECT count(*)
FROM lab14.events_detach
WHERE occurred_at >= TIMESTAMPTZ '2025-01-01 00:00:00+00'
AND occurred_at < TIMESTAMPTZ '2025-02-01 00:00:00+00';
SELECT pg_backend_pid() AS session_a_pid, xact_start
FROM pg_stat_activity
WHERE pid = pg_backend_pid();
-- 保持事务,不提交。
13.3.6 Session B:并发 Detach
DETACH CONCURRENTLY 必须在 Autocommit、事务块外运行:
\timing on
SET lock_timeout = '10s';
SET statement_timeout = '10min';
SELECT pg_current_wal_lsn() AS detach_lsn_before \gset
ALTER TABLE lab14.events_detach
DETACH PARTITION lab14.events_detach_2025_01 CONCURRENTLY;
第一阶段通常可以完成,然后命令等待 Session A 的旧事务结束。不要在 B 中执行 BEGIN。
13.3.7 Session C:观察 Pending Detach 和等待
SELECT
i.inhrelid::regclass AS child,
i.inhparent::regclass AS parent,
i.inhdetachpending
FROM pg_inherits AS i
WHERE i.inhparent = 'lab14.events_detach'::regclass;
SELECT
pid,
state,
xact_start,
query_start,
wait_event_type,
wait_event,
pg_blocking_pids(pid) AS blocking_pids,
left(query, 160) AS query
FROM pg_stat_activity
WHERE query ILIKE '%DETACH PARTITION%'
OR pid IN (
SELECT unnest(pg_blocking_pids(pid))
FROM pg_stat_activity
WHERE query ILIKE '%DETACH PARTITION%'
)
ORDER BY xact_start NULLS LAST;
并发 Detach 的“等待旧事务”不一定始终表现为普通 Relation Lock blocker 文本;结合 inhdetachpending、活动事务年龄和命令状态判断。
13.3.8 Session A:提交,允许 Detach 完成
COMMIT;
Session B 随后完成。若 B 被取消或服务重启,检查 inhdetachpending,必要时执行:
ALTER TABLE lab14.events_detach
DETACH PARTITION lab14.events_detach_2025_01 FINALIZE;
仅在确认目标确实处于 Pending 状态后执行。
13.3.9 Session B:记录 WAL 并删除独立历史表
SELECT
pg_size_pretty(
pg_wal_lsn_diff(pg_current_wal_lsn(), :'detach_lsn_before'::pg_lsn)
) AS detach_wal_generated;
-- 此时它已是独立表,可先归档并验证。
SELECT count(*) AS detached_rows
FROM lab14.events_detach_2025_01;
SELECT
pg_size_pretty(pg_total_relation_size('lab14.events_detach_2025_01'))
AS detached_size;
SELECT pg_current_wal_lsn() AS drop_lsn_before \gset
DROP TABLE lab14.events_detach_2025_01;
SELECT
pg_size_pretty(
pg_wal_lsn_diff(pg_current_wal_lsn(), :'drop_lsn_before'::pg_lsn)
) AS drop_wal_generated;
在真实生命周期中,DROP 前应先:导出、校验、登记归档对象、验证恢复,再按审批删除。
13.3.10 明确时间线
| 时间 | Session A | Session B | Session C/D | 等待/失败/提交 |
|---|---|---|---|---|
| T0 | — | — | D 执行真实 DELETE | Delete 提交并产生 Dead Tuple/WAL |
| T1 | BEGIN 并读旧分区 | — | — | A 保持旧事务 |
| T2 | 保持事务 | 发起 DETACH ... CONCURRENTLY | C 查 Pending | B 第一阶段后等待 A |
| T3 | COMMIT | Detach 第二阶段完成 | C 验证 | A 提交,B 完成 |
| T4 | — | 读取独立表、归档模拟、DROP | 记录 WAL/大小 | 独立历史表删除 |
- 等待步骤:B 在旧事务结束前等待。
- 失败步骤:正常流程无预期失败;若父表有 Default,B 会立即失败;若放在事务块也会失败。
- 提交步骤:D 的 Delete 在 Autocommit 下提交;A 的读事务显式提交;并发 Detach 内部两事务由命令管理。
13.3.11 对比指标
| 指标 | 大量 DELETE | DETACH CONCURRENTLY + DROP | 解释 |
|---|---|---|---|
| 处理行数 | Delete 与行数相关;Detach/Drop 以关系为单位 | ||
| 总耗时 | 分开记录执行与锁等待 | ||
| P50/P95/P99 | 用重复、重置后的独立数据集测量 | ||
| Buffers hit/read/dirtied/written | Delete 通常触及大量 Heap/Index Page | ||
| WAL records/FPI/bytes | 不预设倍数,以实际为准 | ||
| CPU | Delete 执行器和索引维护更重 | ||
| I/O | Delete + Vacuum 与 Drop 文件处理不同 | ||
| Wait Event | Detach 可能主要等待旧事务/锁 | ||
n_dead_tup | Drop 路径不制造逐行 Dead Tuple | ||
| 操作后物理大小 | Delete 文件通常不立即变小,Drop 移除关系 | ||
| Replica Lag | 记录两路径对重放的实际影响 |
要得到 P50/P95/P99,应每次从相同快照或重新装载的独立数据库开始,执行足够轮次;不能在已删除的数据上重复跑同一语句。
13.3.12 清理
DROP TABLE IF EXISTS lab14.events_delete CASCADE;
DROP TABLE IF EXISTS lab14.events_detach CASCADE;
DROP TABLE IF EXISTS lab14.events_detach_2025_01;
全章实验结束:
DROP SCHEMA IF EXISTS lab14 CASCADE;
13.3.13 生产安全警告
DROP TABLE是真实删除;对象名错误可能造成不可逆事故。使用审批、Allowlist、边界复核和 PITR 演练。DETACH CONCURRENTLY不是无锁;长事务会让它长时间处于 Pending。- 父表有 Default 时不能使用并发 Detach。
- 不要为了让 DDL通过而随意终止所有长事务;先识别业务影响。
- 不要用单次运行的 wall-clock 时间宣称方案快多少;必须同时记录 WAL、Buffers、缓存状态、CPU、I/O、等待与副本延迟。
14. Go:独立未来分区维护任务
14.1 架构约束
维护程序不是业务请求处理器。推荐部署为 Kubernetes CronJob、systemd timer 或受调度平台控制的独立任务:
普通请求进程 ──参数化 INSERT/SELECT──> app.event_log
▲
│ 预先存在的未来分区
│
独立 Maintainer ──单实例 + 短超时 DDL──> ensure_event_month(date)
设计原则:
- 请求路径不执行
CREATE TABLE、ATTACH或DROP。 - Go 不拼接 DDL 标识符或日期字面量;它只参数化调用受控数据库函数。
- 数据库函数仅允许月初日期和有限未来窗口,使用固定 Schema、固定父表和安全
search_path。 - 整个任务持有 Session Advisory Lock;每个月创建又持有 Transaction Advisory Lock,防止同月竞态。
- DDL 并发固定为 1,避免锁风暴。
lock_timeout与statement_timeout让任务快速退出,而不是堵住请求。- 只对
40001、40P01、55P03做有界完整事务重试;服从 Context,并使用指数退避与抖动。 - 单独处理
Commit错误:先验证目录状态,不能武断认为事务未提交。 - 使用专用最小权限角色;不把父表 Owner 权限直接授予普通应用。
14.2 数据库侧受控函数
以下函数应由父表 Owner 创建和拥有。若使用 SECURITY DEFINER,必须固定 search_path、撤销 PUBLIC 执行权限,并只开放给维护角色。
CREATE OR REPLACE FUNCTION app.ensure_event_month(p_month date)
RETURNS boolean
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = pg_catalog
SET TimeZone = 'UTC'
AS $$
DECLARE
v_current_month date;
v_next_month date;
v_name text;
v_start_text text;
v_end_text text;
v_existing oid;
BEGIN
IF p_month IS NULL THEN
RAISE EXCEPTION USING
ERRCODE = '22023',
MESSAGE = 'p_month must not be null';
END IF;
IF p_month <> date_trunc('month', p_month)::date THEN
RAISE EXCEPTION USING
ERRCODE = '22023',
MESSAGE = 'p_month must be the first day of a month';
END IF;
v_current_month := date_trunc('month', current_date)::date;
-- 防止被滥用为任意历史/远期 DDL 接口;窗口应按组织策略调整。
IF p_month < (v_current_month - interval '1 month')::date
OR p_month > (v_current_month + interval '24 months')::date THEN
RAISE EXCEPTION USING
ERRCODE = '22023',
MESSAGE = 'p_month is outside the allowed maintenance window';
END IF;
v_next_month := (p_month + interval '1 month')::date;
v_name := format('event_log_%s', to_char(p_month, 'YYYY_MM'));
v_start_text := to_char(p_month, 'YYYY-MM-DD') || ' 00:00:00+00';
v_end_text := to_char(v_next_month, 'YYYY-MM-DD') || ' 00:00:00+00';
-- 同一个月份即使由不同维护实例调用,也只能由一个事务创建。
PERFORM pg_advisory_xact_lock(
hashtextextended('app.event_log:' || p_month::text, 0)
);
SELECT c.oid
INTO v_existing
FROM pg_class AS c
JOIN pg_namespace AS n
ON n.oid = c.relnamespace
WHERE n.nspname = 'app'
AND c.relname = v_name;
IF v_existing IS NOT NULL THEN
IF NOT EXISTS (
SELECT 1
FROM pg_inherits AS i
WHERE i.inhparent = 'app.event_log'::regclass
AND i.inhrelid = v_existing
) THEN
RAISE EXCEPTION USING
ERRCODE = '42P07',
MESSAGE = format('relation app.%I exists but is not a direct partition of app.event_log', v_name);
END IF;
RETURN false;
END IF;
EXECUTE format(
'CREATE TABLE app.%I PARTITION OF app.event_log '
'FOR VALUES FROM (%L) TO (%L)',
v_name,
v_start_text,
v_end_text
);
RETURN true;
END;
$$;
REVOKE ALL ON FUNCTION app.ensure_event_month(date) FROM PUBLIC;
GRANT EXECUTE ON FUNCTION app.ensure_event_month(date)
TO partition_maintainer;
安全说明:
- 函数 Owner 应与
app.event_logOwner 对齐,且维护角色只获EXECUTE。 SECURITY DEFINER函数是高权限边界,必须进入 Schema Migration 审核、单元测试和安全审计。- 函数使用
format('%I', ...)引用受控标识符、%L引用受控时间字面量;输入不能改变父表或 Schema。 CREATE TABLE IF NOT EXISTS只验证名称,不验证定义,因此函数先确认同名对象确实属于目标父表;Go 再验证实际边界。- 父级索引、约束和用户定义行级触发器会按 PostgreSQL 规则复制到新分区;维护后仍应检查索引有效性、Owner、Privileges 和 Tablespace。
14.3 可编译的 pgx/v5 示例
初始化依赖:
go mod init example.com/partition-maintainer
go get github.com/jackc/pgx/v5
export DATABASE_URL='postgres://partition_maintainer:***@db.example/app?sslmode=verify-full'
go run .
package main
import (
"context"
"errors"
"fmt"
"log/slog"
"math/rand"
"os"
"os/signal"
"strings"
"syscall"
"time"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgconn"
"github.com/jackc/pgx/v5/pgxpool"
)
const (
monthsAhead = 6
maxAttempts = 3
)
type ensureResult struct {
Created bool
CommitVerifiedAfterError bool
}
type queryRower interface {
QueryRow(context.Context, string, ...any) pgx.Row
}
func main() {
if err := run(); err != nil {
slog.Error("partition maintenance failed", "error", err)
os.Exit(1)
}
}
func run() error {
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
defer stop()
databaseURL := os.Getenv("DATABASE_URL")
if databaseURL == "" {
return errors.New("DATABASE_URL is required")
}
cfg, err := pgxpool.ParseConfig(databaseURL)
if err != nil {
return fmt.Errorf("parse pool config: %w", err)
}
cfg.MaxConns = 2
cfg.MinConns = 0
cfg.MaxConnIdleTime = 2 * time.Minute
cfg.MaxConnLifetime = 30 * time.Minute
cfg.ConnConfig.RuntimeParams["application_name"] = "partition-maintainer"
pool, err := pgxpool.NewWithConfig(ctx, cfg)
if err != nil {
return fmt.Errorf("create pool: %w", err)
}
defer pool.Close()
pingCtx, cancelPing := context.WithTimeout(ctx, 5*time.Second)
err = pool.Ping(pingCtx)
cancelPing()
if err != nil {
return fmt.Errorf("ping database: %w", err)
}
// A session-level advisory lock makes the whole run single-instance.
// It must stay on one acquired connection for the duration of the job.
conn, err := pool.Acquire(ctx)
if err != nil {
return fmt.Errorf("acquire maintenance connection: %w", err)
}
defer conn.Release()
locked, err := tryJobLock(ctx, conn)
if err != nil {
return fmt.Errorf("acquire advisory lock: %w", err)
}
if !locked {
slog.Info("another partition maintainer is active; exiting")
return nil
}
defer unlockJob(conn)
rng := rand.New(rand.NewSource(time.Now().UnixNano()))
now := time.Now().UTC()
firstMonth := time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, time.UTC)
// DDL is intentionally sequential. Concurrency one is a bounded policy and
// avoids creating a lock storm against the same partitioned table.
for i := 0; i < monthsAhead; i++ {
month := firstMonth.AddDate(0, i, 0)
result, err := ensureMonthWithRetry(ctx, pool, conn, month, rng)
if err != nil {
return fmt.Errorf("ensure month %s: %w", month.Format("2006-01"), err)
}
slog.Info(
"month partition verified",
"month", month.Format("2006-01"),
"created", result.Created,
"commit_verified_after_error", result.CommitVerifiedAfterError,
)
}
listCtx, cancelList := context.WithTimeout(ctx, 10*time.Second)
err = listPartitions(listCtx, conn)
cancelList()
if err != nil {
return fmt.Errorf("list partitions: %w", err)
}
stat := pool.Stat()
slog.Info(
"partition maintenance completed",
"acquired_conns", stat.AcquiredConns(),
"idle_conns", stat.IdleConns(),
"empty_acquire_count", stat.EmptyAcquireCount(),
"empty_acquire_wait", stat.EmptyAcquireWaitTime(),
)
return nil
}
func tryJobLock(ctx context.Context, conn *pgxpool.Conn) (bool, error) {
var locked bool
err := conn.QueryRow(
ctx,
`SELECT pg_try_advisory_lock($1, $2)`,
int32(140014),
int32(1),
).Scan(&locked)
return locked, err
}
func unlockJob(conn *pgxpool.Conn) {
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
var unlocked bool
err := conn.QueryRow(
ctx,
`SELECT pg_advisory_unlock($1, $2)`,
int32(140014),
int32(1),
).Scan(&unlocked)
if err != nil {
slog.Error("release advisory lock", "error", err)
return
}
if !unlocked {
slog.Warn("advisory lock was not held when releasing")
}
}
func ensureMonthWithRetry(
ctx context.Context,
pool *pgxpool.Pool,
conn *pgxpool.Conn,
month time.Time,
rng *rand.Rand,
) (ensureResult, error) {
var lastErr error
for attempt := 1; attempt <= maxAttempts; attempt++ {
result, err := ensureMonthOnce(ctx, pool, conn, month)
if err == nil {
return result, nil
}
lastErr = err
if !isRetryable(err) || attempt == maxAttempts {
break
}
base := 250 * time.Millisecond * time.Duration(1<<(attempt-1))
jitter := time.Duration(rng.Int63n(int64(200 * time.Millisecond)))
delay := base + jitter
slog.Warn(
"retrying partition DDL",
"month", month.Format("2006-01"),
"attempt", attempt,
"delay", delay,
"error", err,
)
timer := time.NewTimer(delay)
select {
case <-ctx.Done():
if !timer.Stop() {
<-timer.C
}
return ensureResult{}, ctx.Err()
case <-timer.C:
}
}
return ensureResult{}, fmt.Errorf("partition %s not ensured: %w", month.Format("2006-01"), lastErr)
}
func ensureMonthOnce(
ctx context.Context,
pool *pgxpool.Pool,
conn *pgxpool.Conn,
month time.Time,
) (ensureResult, error) {
tx, err := conn.BeginTx(ctx, pgx.TxOptions{})
if err != nil {
return ensureResult{}, fmt.Errorf("begin transaction: %w", err)
}
defer func() {
rollbackCtx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
_ = tx.Rollback(rollbackCtx)
}()
// These constants are controlled by the program, not user input.
if _, err := tx.Exec(ctx, `SET LOCAL lock_timeout = '2s'`); err != nil {
return ensureResult{}, fmt.Errorf("set lock_timeout: %w", err)
}
if _, err := tx.Exec(ctx, `SET LOCAL statement_timeout = '30s'`); err != nil {
return ensureResult{}, fmt.Errorf("set statement_timeout: %w", err)
}
var created bool
monthDate := month.Format("2006-01-02")
if err := tx.QueryRow(
ctx,
`SELECT app.ensure_event_month($1::date)`,
monthDate,
).Scan(&created); err != nil {
return ensureResult{}, classifyPgError("ensure_event_month", err)
}
if err := tx.Commit(ctx); err != nil {
// Commit errors can have an uncertain result. Verify catalog state before
// deciding whether retrying is safe.
verifyCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
// Verify through the pool, not the transaction connection: that
// connection may have been lost at the exact commit boundary.
if verifyErr := verifyPartition(verifyCtx, pool, month); verifyErr == nil {
return ensureResult{
Created: created,
CommitVerifiedAfterError: true,
}, nil
}
return ensureResult{}, fmt.Errorf("commit outcome uncertain: %w", err)
}
if err := verifyPartition(ctx, conn, month); err != nil {
return ensureResult{}, fmt.Errorf("post-commit partition verification: %w", err)
}
return ensureResult{Created: created}, nil
}
func verifyPartition(ctx context.Context, q queryRower, month time.Time) error {
partitionName := "event_log_" + month.Format("2006_01")
var relationName string
var bound string
err := q.QueryRow(
ctx,
`SELECT
c.oid::regclass::text,
pg_get_expr(c.relpartbound, c.oid)
FROM pg_class AS c
JOIN pg_namespace AS n ON n.oid = c.relnamespace
JOIN pg_inherits AS i ON i.inhrelid = c.oid
WHERE n.nspname = 'app'
AND c.relname = $1
AND i.inhparent = 'app.event_log'::regclass`,
partitionName,
).Scan(&relationName, &bound)
if errors.Is(err, pgx.ErrNoRows) {
return fmt.Errorf("partition %s is absent", partitionName)
}
if err != nil {
return classifyPgError("verify partition", err)
}
nextMonth := month.AddDate(0, 1, 0)
if !strings.Contains(bound, month.Format("2006-01-02")) ||
!strings.Contains(bound, nextMonth.Format("2006-01-02")) {
return fmt.Errorf("partition %s has unexpected bound %q", relationName, bound)
}
return nil
}
func listPartitions(ctx context.Context, conn *pgxpool.Conn) error {
rows, err := conn.Query(
ctx,
`SELECT
p.relid::text,
COALESCE(p.parentrelid::text, ''),
p.isleaf,
p.level,
COALESCE(pg_get_expr(c.relpartbound, c.oid), '')
FROM pg_partition_tree('app.event_log') AS p
JOIN pg_class AS c ON c.oid = p.relid
ORDER BY p.level, p.relid::text`,
)
if err != nil {
return classifyPgError("query partition tree", err)
}
defer rows.Close()
for rows.Next() {
var relation string
var parent string
var isLeaf bool
var level int
var bound string
if err := rows.Scan(&relation, &parent, &isLeaf, &level, &bound); err != nil {
return fmt.Errorf("scan partition row: %w", err)
}
slog.Info(
"partition",
"relation", relation,
"parent", parent,
"is_leaf", isLeaf,
"level", level,
"bound", bound,
)
}
if err := rows.Err(); err != nil {
return fmt.Errorf("iterate partition rows: %w", err)
}
return nil
}
func isRetryable(err error) bool {
var pgErr *pgconn.PgError
if !errors.As(err, &pgErr) {
return false
}
switch pgErr.Code {
case "40001", // serialization_failure
"40P01", // deadlock_detected
"55P03": // lock_not_available / lock_timeout
return true
default:
return false
}
}
func classifyPgError(operation string, err error) error {
var pgErr *pgconn.PgError
if !errors.As(err, &pgErr) {
return fmt.Errorf("%s: %w", operation, err)
}
return fmt.Errorf(
"%s: sqlstate=%s constraint=%s table=%s detail=%s: %w",
operation,
pgErr.Code,
pgErr.ConstraintName,
pgErr.TableName,
pgErr.Detail,
err,
)
}
14.4 代码审查要点
signal.NotifyContext:SIGINT/SIGTERM 取消当前 SQL、退避等待和后续月份。pgxpool.ParseConfig/NewWithConfig:配置来自DATABASE_URL,池在退出时关闭。MaxConns = 2:一个连接持有 Session Advisory Lock 并串行执行 DDL;另一个仅用于 Commit 结果不确定时从新会话复核目录。pool.Acquire:Session Advisory Lock 必须绑定同一物理连接;defer conn.Release()防泄漏。BeginTx+defer Rollback:每月创建是完整事务;Rollback 使用短 Background Context,避免主 Context 已取消时无法清理。SET LOCAL:超时只影响当前事务,不污染归还池中的连接。$1::date:日期参数化;Go 不拼 DDL。errors.As+*pgconn.PgError:按 SQLSTATE 分类,不依赖错误文本。maxAttempts、指数退避和随机抖动:防止多个任务同步重试形成风暴。Commit错误后通过池中的另一连接验证 Catalog:原事务连接可能恰在提交边界断开,不能依赖它,也不能盲目重试。rows.Close()与rows.Err():正确归还连接并检查迭代错误。listPartitions:后置输出树、层级和边界,可作为审计日志;真实系统还应把结构化指标发送到监控系统。
14.5 不应放进普通请求路径的伪代码
// 禁止:大量并发请求在月初同时做 DDL。
func HandleInsert(ctx context.Context, event Event) error {
if noPartition(event.OccurredAt) {
// CREATE TABLE ... // 强锁、竞态、连接池耗尽、注入风险
}
return insertEvent(ctx, event)
}
正确行为是参数化 INSERT;若 SQLSTATE 表示没有目标分区,快速失败、记录目标时间、触发告警。维护系统应在此之前数周创建分区。
15. 生产 Runbook
15.1 首先确认什么
先建立事实时间线,不要直接改参数:
- 故障起点、首次告警、最近一次分区 DDL、应用发布和统计信息更新分别在何时。
- 影响是写入失败、查询变慢、连接池耗尽、DDL 等待、Replica Lag,还是历史数据误删。
- 影响一个租户/时间窗,还是所有访问父表的请求。
- SQL 是否包含分区键和半开区间,实际参数类型和时区是什么。
- 是否刚跨日/月/年边界,未来分区是否存在,Default 是否突然有数据。
- 是否存在长事务、
idle in transaction、备份、报表或维护任务。 - 当前 Primary 身份是否明确,是否刚发生 Switchover/Failover,旧维护任务是否仍在运行。
应用错误日志必须保留 SQLSTATE、Schema、Table、Constraint 和目标时间参数;不要只保留错误文本。
15.2 查看哪些指标
按同一时间轴对齐:
- API P50/P95/P99、错误率、超时率、队列长度。
- pgxpool
AcquiredConns、EmptyAcquireCount、EmptyAcquireWaitTime、取消次数。 pg_stat_statements的调用量、总/平均规划时间、执行时间、Rows、Temp Blocks、WAL(版本支持时)。- 主机 CPU、Load、RSS、Page Fault、磁盘 IOPS/吞吐/延迟/队列深度。
- [PG16+]
pg_stat_io的 Backend、Autovacuum、Checkpointer I/O。 - WAL 生成速率、归档滞后、Checkpoint、Replica Send/Write/Flush/Replay Lag。
- Autovacuum 活动、Dead Tuple、Freeze Age。
- 父表和叶分区数量、Default 行数、Pending Detach、未来覆盖窗口。
15.3 查询哪些系统视图
分区清单、边界、大小和行数估算
SELECT
p.level,
p.relid::regclass AS relation,
p.parentrelid::regclass AS parent,
p.isleaf,
pg_get_expr(c.relpartbound, c.oid) AS bound,
c.reltuples::bigint AS estimated_rows,
pg_size_pretty(pg_total_relation_size(c.oid)) AS total_size
FROM pg_partition_tree('app.event_log') AS p
JOIN pg_class AS c
ON c.oid = p.relid
ORDER BY p.level, relation::text;
level:0 为根,1 为一级分区。isleaf:是否真正存储数据。bound:边界文本。reltuples:Analyze 后的估算,不是精确 Count。total_size:父虚拟表可能接近零;容量统计关注叶分区。
Default 是否异常积压
SELECT
count(*) AS rows,
min(occurred_at) AS min_key,
max(occurred_at) AS max_key,
pg_size_pretty(pg_total_relation_size('app.event_log_default')) AS total_size
FROM app.event_log_default;
该 Count 对巨大 Default 可能昂贵;日常监控应使用增量业务计数、近似统计或按小窗口抽样,不要每分钟全表 Count。
叶分区 Vacuum/Analyze
SELECT
s.relid::regclass AS relation,
s.n_live_tup,
s.n_dead_tup,
s.n_mod_since_analyze,
s.last_vacuum,
s.last_autovacuum,
s.last_analyze,
s.last_autoanalyze,
s.vacuum_count,
s.autovacuum_count,
age(c.relfrozenxid) AS xid_age
FROM pg_stat_all_tables AS s
JOIN pg_class AS c ON c.oid = s.relid
WHERE s.relid IN (
SELECT relid FROM pg_partition_tree('app.event_log') WHERE isleaf
)
ORDER BY s.n_dead_tup DESC;
父级和叶级索引有效性
SELECT
t.relid::regclass AS table_name,
i.indexrelid::regclass AS index_name,
i.indisvalid,
i.indisready,
i.indislive,
pg_size_pretty(pg_relation_size(i.indexrelid)) AS index_size
FROM pg_partition_tree('app.event_log') AS t
JOIN pg_index AS i
ON i.indrelid = t.relid
ORDER BY table_name::text, index_name::text;
indisvalid=false:Planner 不应把它作为普通有效索引使用;在线构建失败或父级尚未 Attach 完整时常见。indisready:是否接受新写入维护。indislive:是否仍被视为存活索引。
Pending Detach
SELECT
inhrelid::regclass AS child,
inhparent::regclass AS parent,
inhdetachpending
FROM pg_inherits
WHERE inhdetachpending;
15.4 如何找到 blocker
WITH waiting AS (
SELECT
a.pid,
a.usename,
a.application_name,
a.xact_start,
a.query_start,
a.wait_event_type,
a.wait_event,
a.query,
unnest(pg_blocking_pids(a.pid)) AS blocker_pid
FROM pg_stat_activity AS a
WHERE cardinality(pg_blocking_pids(a.pid)) > 0
)
SELECT
w.pid AS blocked_pid,
w.application_name AS blocked_app,
clock_timestamp() - w.query_start AS blocked_for,
w.wait_event_type,
w.wait_event,
b.pid AS blocker_pid,
b.application_name AS blocker_app,
b.state AS blocker_state,
clock_timestamp() - b.xact_start AS blocker_xact_age,
left(w.query, 180) AS blocked_query,
left(b.query, 180) AS blocker_query
FROM waiting AS w
JOIN pg_stat_activity AS b
ON b.pid = w.blocker_pid
ORDER BY blocker_xact_age DESC NULLS LAST;
处理顺序:确认 blocker 身份和事务目的 → 判断能否让业务自行完成 → 必要时取消 Query → 最后才考虑终止 Session。pg_cancel_backend 只取消当前语句;pg_terminate_backend 会断开连接并回滚事务,风险更高。
15.5 如何找到最早出现的计划估算错误
PostgreSQL 不保存所有历史执行计划。要定位“最早何时出现”,必须结合历史监控:
- 从
pg_stat_statements找mean_plan_time、total_plan_time、执行时间或 Rows 在何时突变。 - 对相同
queryid关联发布、分区创建、Attach、Analyze 和数据倾斜时间。 - 在安全环境对代表性参数执行
EXPLAIN ANALYZE,比较每个节点rows=估算与actual rows=。 - 若生产已启用受控
auto_explain,搜索最早估算倍率突变的日志;不要临时全局记录所有高频查询。 - 检查新分区是否批量装载后未 Analyze、父表是否长期未手工 Analyze。
EXPLAIN (ANALYZE, BUFFERS, WAL, SETTINGS, VERBOSE, SUMMARY)
SELECT ...
FROM app.event_log
WHERE occurred_at >= $1
AND occurred_at < $2;
估算倍率可按 greatest(actual,1) / greatest(estimated,1) 观察。单个节点误差未必导致坏计划;关注它是否改变 Join 顺序、扫描方式、并行度或内存溢写。
15.6 如何区分 CPU、内存、I/O、锁、连接池、WAL、Vacuum 和复制问题
| 类型 | 典型证据 | 分区相关判断 |
|---|---|---|
| CPU | Backend CPU 高、无明显 I/O/锁等待、Planning Time 上升 | 过多分区、复杂 Partition-Wise 计划、全树本地索引探测 |
| 内存 | Backend RSS 上升、OOM、临时文件或 Hash 批次 | 大量计划节点、每分区 work_mem 节点、长连接触碰全树 |
| I/O | DataFileRead、设备延迟/队列高、Buffers read 高 | 无裁剪跨历史、冷分区扫描、批量装载/搬 Tablespace |
| 锁 | wait_event_type='Lock'、pg_blocking_pids 非空 | Attach/Drop/普通 Detach/父级索引 DDL排队 |
| 连接池 | DB 不忙但 Acquire Wait 高,连接被长查询占用 | 跨分区查询和 DDL 等待延长连接持有时间 |
| WAL | WAL bytes/s、归档和 Checkpoint 激增 | 大量 Delete、跨分区 Update、建索引、批量装载 |
| Vacuum | Dead Tuple、XID Age、Autovacuum 长时间运行 | 当前热分区阈值不合适;Delete 路径制造大量垃圾 |
| 复制 | replay_lag、LSN 差、Standby I/O/Recovery Conflict | DDL、Delete/Vacuum WAL、索引构建或 Tablespace 迁移 |
15.7 哪些命令通常可在线执行
“在线”仍要设置超时并观察:
EXPLAIN不带ANALYZE。- 查询
pg_partition_tree、pg_stat_*、pg_locks、pg_class。 - 对单个新装载叶分区执行
ANALYZE;父表ANALYZE需评估 I/O。 - 创建独立 Staging Table、离线装载和校验。
- 叶分区
CREATE INDEX CONCURRENTLY。 - 在无 Default 且已评估长事务时执行
DETACH ... CONCURRENTLY。 - 将
lock_timeout/statement_timeout设为 Session 或 Transaction 局部值。
15.8 哪些命令高风险
DROP TABLE历史分区。CREATE TABLE ... PARTITION OF、普通DETACH、直接 Drop 分区导致父表强锁。- 有巨大 Default 时
ATTACH PARTITION。 - 父表直接
CREATE INDEX,因不支持父级CONCURRENTLY。 ALTER TABLE ... SET TABLESPACE搬大分区。- 对数亿行运行
EXPLAIN ANALYZE DELETE。 - 全局强制
plan_cache_mode、打开 Partition-Wise Join/Aggregate 或大幅提高work_mem。 - 终止未知长事务、关闭 Autovacuum、关闭持久性参数。
15.9 临时止损
按事故类型选择最小动作:
- 缺失未来分区:由专用维护身份在短
lock_timeout下创建;失败则对超出窗口写入 Backpressure,而非请求内 DDL。 - 全历史慢查询:限流、取消、缩小允许时间窗,临时路由到分析副本。
- DDL 锁队列:先取消等待中的非必要 DDL,解除队列,再处理根 blocker。
- Default 积压:停止继续流入,建立迁移窗口;不要立即 Attach 触发全表扫描。
- 大 Delete 引发 WAL/Replica Lag:暂停批次,降低并发,改用后续整分区生命周期设计。
- Pool 耗尽:Admission Control,保留健康检查/运维连接预算;不要单纯无限增大 MaxConns。
15.10 根本修复
- 重选与高频谓词和保留策略一致的分区键。
- 改写 SQL 为分区键半开范围,并验证参数类型。
- 预建未来分区,建立 Default 行数和覆盖窗口告警。
- 调整日/月/年粒度,减少不必要层级和索引。
- 建立父表手工 Analyze 与新叶 Analyze 流程。
- 用注册表或 Hash 分区解决全局 ID,而非触发器全树查重。
- 将 DDL 与请求隔离,使用单实例、有界重试维护任务。
- 对历史删除采用 Detach → 归档验证 → Drop 的审批流程。
15.11 如何验证修复
pg_partition_tree边界、层级和未来覆盖正确。- Default 行数归零且持续不增长。
- 代表性常量、Generic Plan 和最坏参数均只访问预期分区。
- Planning Time、Buffers、P95/P99、Pool Wait 回到基线。
- 无 Pending Detach、Invalid Index 或异常强锁等待。
- 叶和父统计更新时间正常,估算倍率在可接受范围。
- WAL/Replica Lag 恢复,Standby 查询无新增冲突。
- 在隔离环境完成归档恢复或 PITR 演练。
15.12 监控和告警
至少设置:
- 未来可写覆盖
< N天/月。 - Default
rows > 0、最老 Default 行年龄和增长速率。 - 分区数量、叶数量和索引对象数量异常增长。
- Query Planning P95、计划/执行时间比例、访问分区数异常。
- DDL Lock Wait、最长事务、
idle in transaction。 - Pending Detach 持续超过阈值。
- 新分区创建失败、连续重试、边界验证失败。
- 叶分区
last_autoanalyze/last_autovacuum过旧、XID Age 过高。 - 父表
last_analyze超过策略周期。 - WAL、归档和 Replica Replay Lag 超阈值。
- pgxpool Empty Acquire Wait、排队和取消率上升。
16. 常见错误与反模式
- “超过一千万行就分区”:忽略行宽、索引、查询、保留期和硬件;先用 SLO 与维护需求证明必要性。
- 把分区当索引:只按月分区却不建立租户/业务谓词索引,单月内仍全表扫描。
- 查询不带分区键:让每个叶分区分别扫描或探测索引,读放大与 Planning Time 随保留期增长。
- 每租户一个分区:客户数从数百增长到数十万后,Catalog、规划和每会话内存失控。
- 默认分区永久兜底:不监控、不迁移,最终 Attach 新分区需要扫描巨表并强锁。
- 请求路径动态建分区:月初并发 DDL、连接池耗尽、强锁和重试风暴。
- 认为
DETACH CONCURRENTLY无等待:忽略它要等待使用旧分区树的事务,且第二阶段仍需叶表强锁。 - 父表直接在线建大索引:父级不支持
CREATE INDEX CONCURRENTLY,递归锁定大树。 - 用触发器全树查询保证唯一:并发竞态且成本随分区数增长,不能替代全局仲裁。
- 只 Analyze 叶、不 Analyze 父:跨分区查询缺少全树统计,估算与 Join 策略可能失真。
- 分区键允许频繁更新:每次跨边界移动带来双侧 Heap/Index/WAL 和更复杂 FK/CDC 行为。
- 按本地时区随意切日:夏令时和会话 TimeZone 导致边界、查询参数和归档窗口不一致。
- 冷热 Tablespace 当备份:存储层故障、Failover 或备份缺失时仍会丢失冷分区。
- Drop 前不验证归档恢复:有导出文件不等于可恢复,误删后才发现对象损坏或缺列。
- 为解决单节点上限继续细分分区:分区不增加 CPU、WAL 带宽或 Primary 数量;应进入分片/读扩展设计。
17. 模拟生产事故案例
17.1 模拟生产案例一:Default Partition 在月初吞入数亿行
1. 系统背景
事件平台每天写入约数亿条事件,根表按月 RANGE(occurred_at),保留 24 个月。为避免缺失分区导致写入中断,团队建立了 Default Partition。独立维护任务原计划每月提前创建未来三个分区。
2. 故障现象
7 月 1 日后写入未失败,但八小时后:
- Default 从近零增长到数亿行;
- DBA 尝试 Attach 七月分区,命令等待并造成父表请求排队;
- 取消 blocker 后 Attach 扫描 Default,最终因存在七月行失败;
- API P99、pgxpool Acquire Wait 和 Replica Lag 同时上升;
- 报表查询因 Default 被包含而读取异常多数据。
3. 错误假设
- “有 Default 就不会发生月初事故。”
- “Attach 只是元数据操作,很快完成。”
- “先建空七月分区,PostgreSQL 会自动把 Default 的行搬过去。”
三者都错误。Default 只把显式失败变成隐性积压;Attach 不自动迁移行,并需要验证 Default。
4. 排查过程
- 从应用日志确认写入没有错误,但物理
tableoid全部指向 Default。 - 查询 Default 的
min/max(occurred_at),确认从 7 月 1 日开始持续流入。 pg_locks显示 Attach 请求 Default 的ACCESS EXCLUSIVE,被长报表的 AccessShareLock 阻塞。- 取消 Attach 后,业务锁队列迅速下降,确认 DDL 是放大器而非最初根因。
- 检查维护任务日志:上次 Failover 后 Secret 未同步,任务连续失败但告警仅发送到已废弃频道。
- 检查
pg_partition_tree:只覆盖到六月,没有七月及以后。 - 对 Replica Lag 与 WAL 关联:团队尝试的批量迁移 Delete/Insert 产生大量 WAL,进一步加剧延迟。
5. 根因
- 直接根因:未来七月分区未创建。
- 控制根因:Failover 后维护身份失效,且没有未来覆盖窗口告警。
- 设计根因:Default 被当成正常写入路径,没有零行 SLO、流量熔断和迁移 Runbook。
- 事故放大:在业务高峰直接发起 Attach,强锁进入队列;随后无计划批量迁移加重 WAL。
6. 临时止损
- 取消等待中的 Attach,先解除父表锁队列。
- 对七月事件入口施加 Backpressure,将可重放事件写入外部持久队列;不在请求中建表。
- 在独立 Staging Table 建七月边界、索引和约束。
- 分批从 Default 向 Staging 迁移历史积压,控制 WAL 和 Replica Lag。
- 进入短维护窗口,阻断父表新写,搬迁最后增量,为 Default 添加排除约束并 Attach。
- 验证 Default 归零、七月查询裁剪和 Replica 追平后恢复入口。
7. 最终修复
- 默认预建未来六个月,维护任务每天运行并幂等验证,而不是每月只运行一次。
- 用 Session Advisory Lock 和专用最小权限角色;Failover 后任务由唯一 Active 调度器接管。
- 将“未来覆盖少于 90 天”设为严重告警。
- Default 改为异常缓冲区:任何新行立即告警;评估后最终移除 Default,让遗漏快速失败。
- 建立 Default 迁移和 Attach 锁演练,发布前在等比例数据上测 WAL、锁和 RTO。
8. 监控补充
- 未来最大边界与当前时间差。
- Default 行数、每分钟增长、最老/最新键值。
- 维护任务最后成功时间、SQLSTATE 和连续失败次数。
- DDL Lock Wait、最长事务、Pool Empty Acquire Wait。
- WAL 生成与 Replica Replay Lag。
9. 防止复发
将分区日历纳入发布门禁和每日巡检;每次 Switchover/Failover 都验证维护身份、调度器单活、未来分区、Tablespace 和权限。Default 不再作为“无需维护”的借口。
17.2 模拟生产案例二:过度分区与无时间谓词导致 Planning P99 爆炸
1. 系统背景
订单分析系统保留五年数据。架构最初按天 Range,再按 tenant_id 做 16 路 Hash 子分区:约 29,000 个叶分区。orders 和 refunds 使用相似结构,每个叶分区有四个索引。升级 PostgreSQL 18 后,团队认为“新版本已优化大量分区”,并全局开启 Partition-Wise Join/Aggregate。
2. 故障现象
- 客户详情接口只按
order_id查询,P99 从几十毫秒恶化到数秒。 - Backend CPU 高,但存储 IOPS 并不高。
pg_stat_statements显示 Planning Time 占总延迟大头。- 单个报表 Backend RSS 急剧增长,主机发生 OOM Kill。
- pgxpool 连接被慢规划和大计划长期占用,排队请求激增。
3. 错误假设
- “PG18 能处理几千分区,所以几万也没问题。”
- “每个分区有
order_id索引,点查一定只需一次索引访问。” - “Partition-Wise Join/Aggregate 全局打开只会更快。”
官方改进降低了部分成本,但前提仍是典型查询能裁剪到少量分区;本地索引和分区级计划都可能按分区倍增。
4. 排查过程
EXPLAIN显示无日期条件的order_id查询保留数万子计划。EXPLAIN ANALYZE的实际数据访问很少,但 Planning Time 极高;证明瓶颈发生在执行前。pg_stat_statements按 Query ID 对比发布前后,发现全局 GUC 变更后计划节点和内存进一步增加。- Backend RSS、[PG17+]
EXPLAIN (MEMORY)与计划节点数量相关。 pg_locks显示单查询涉及大量关系锁;长连接进程内存也因触碰大量关系持续偏高。- 关闭目标 Session 的 Partition-Wise 设置后,OOM 风险下降但无时间谓词点查仍慢。
- 数据模型复盘发现:订单 ID 本身可映射到创建月份,但应用未保存/传递该路由信息。
5. 根因
- 分区粒度和二级 Hash 的乘法导致对象数量远超收益。
- 高频点查谓词不包含时间分区键,无法裁剪。
- 全局开启 Partition-Wise 功能增加计划节点、规划 CPU 和内存。
- 团队把版本优化当成架构上限承诺,没有对真实最坏 SQL做压测。
6. 临时止损
- 对在线角色关闭 Partition-Wise Join/Aggregate,仅在经验证的分析事务局部开启。
- API 强制携带订单创建月;从订单路由缓存/小型目录表补齐时间范围。
- 对全历史报表限流并路由到分析副本,设置最大时间窗。
- 降低报表并发,保护连接池和主库内存;取消最坏查询。
- 暂停新增更细子分区,不继续放大对象数。
7. 最终修复
- 新表改为月 Range;只有极大月份才有选择地二级分区,而非每月固定 16 路。
- 在线点查 API 使用
(order_month, order_id)复合定位;建立路由目录支持仅有 ID 的入口。 - 对需要
UNIQUE(order_id)的域评估按 ID Hash 分区或独立 ID 注册表。 - 归档超过在线查询窗口的历史数据,减少在线树规模。
- Partition-Wise 功能改为 Query/Role 局部策略,必须通过 Planning Time、Memory、Temp 和 P99 门禁。
8. 监控补充
- 每棵树叶分区数、索引数、月增长趋势。
- Query ID 的 Planning P95/P99、计划/执行时间比。
- 每 Backend RSS、进程 OOM、关系锁数量。
- 查询实际访问分区数和最坏时间窗口。
- pgxpool Acquire Wait 与跨全树查询并发。
9. 防止复发
在 Schema 评审中强制提交“保留期 × 一级粒度 × 二级分区数 × 索引数”的对象预算;对不含分区键的关键 SQL做自动计划测试。版本升级优化只能降低常数,不能推翻复杂度模型。
18. 面试题
18.1 核心概念题(5 道)
面试题 1:分区与索引分别解决什么问题?分区能否替代索引?
题目
请解释 Declarative Partitioning 与普通 B-tree 索引的职责边界,并说明为什么“已经按月分区”不代表月内查询不需要索引。
30 秒回答
分区先根据 Partition Bound 排除不可能包含目标行的叶表,降低需要访问的数据对象数量;索引在保留下来的叶表内定位行。Pruning 依据分区边界而不是索引定义。因此,按月分区只能排除其他月份;查询某个月内某个租户、设备或订单时,仍通常需要叶级索引。分区不能替代索引,索引也不能替代按分区 Drop 的生命周期能力。
深入回答
Planner 从父表的分区描述中构造子计划,并在规划期、执行初始化期或执行过程中根据谓词与参数裁剪叶分区。剩余叶表可采用 Sequential Scan、Bitmap Scan 或 Index Scan。分区的主要优点是生命周期管理、批量淘汰、冷热布局以及在高选择性分区键查询中缩小工作集;缺点是对象、锁、统计、计划节点和运维复杂度增加。索引的优点是叶内快速定位与排序支持;缺点是写放大、WAL、空间和 Vacuum 成本。替代方案包括不分区的大表加合适索引、BRIN、汇总表、归档库或真正的分片。生产中要同时验证 Subplans Removed、实际访问叶数、Planning Time 和叶内访问路径。
面试官真正考察什么
是否能把“分区裁剪”和“索引访问”分成两个层次,避免把所有性能问题归因于表太大。
常见错误回答
“分区相当于自动索引,所以分区键以外不需要索引。”
追问
按月分区的事件表执行 WHERE tenant_id = $1,每个叶表都有 (tenant_id) 索引,会发生什么?
追问答案
没有时间谓词时不能按月份裁剪,Planner/Executor 可能为全部月份建立并执行本地索引探测。单次探测虽快,但总成本约随叶分区数增长;替代方案是让调用方携带时间范围、维护 ID 到月份的路由目录、改用与点查键一致的分区策略,或保留非分区表。生产上要测试最坏保留期,而不是只测两个分区的开发库。
面试题 2:Range、List、Hash Partition 如何选择?
题目
分别说明 Range、List、Hash Partition 的适用场景、优缺点和常见误用。
30 秒回答
Range 适合时间、序号等连续区间和保留期管理;List 适合有限、稳定、业务语义明确的离散集合;Hash 适合按键均匀摊开写入或容量,但不适合按时间快速淘汰。选择标准不是语法偏好,而是查询谓词、数据生命周期、唯一性约束、倾斜和运维动作是否与 Partition Key 对齐。
深入回答
Range 的 Bound 是半开区间,易做日/月/年滚动、Detach/Drop 与冷热迁移;边界遗漏和时区错误是主要风险。List 可按地区、监管域、产品线隔离,易进行有针对性的维护;集合增长失控、值迁移和 Default 积压会提高复杂度。Hash 按 Modulus/Remainder 分布,能把单键负载铺到多个叶表,但无法通过删除一个 Hash 叶表表达“删除某个月”,也不能建立 Default Partition。多级分区可组合时间 Range 与租户 Hash/List,但对象数是乘法关系。替代方案包括单表、部分索引、BRIN、物理分片。生产中先列出高频 SQL、保留动作和对象预算,再决定策略。
面试官真正考察什么
是否能从访问模式和生命周期推导分区方式,而不是背诵三种语法。
常见错误回答
“Hash 一定最均匀,所以所有大表都应 Hash 分区。”
追问
日志按月淘汰,但某些大客户单月数据极多,应如何设计?
追问答案
通常以月份 Range 作为一级分区保证 Drop 粒度;只有月内单叶在容量、索引维护或并行性上确有压力时,才对特定月份或全部月份做有限 Hash 子分区。优点是兼顾时间淘汰与月内分散;缺点是叶表、索引和计划节点倍增。也可按月单层分区并通过更快存储、合适索引、批量写入和归档解决,先以压测证明二级分区必要性。
面试题 3:Planning-Time Pruning 与 Execution-Time Pruning 有何区别?
题目
请解释分区裁剪发生的三个时机,以及 EXPLAIN 中如何判断运行时裁剪。
30 秒回答
规划期裁剪使用规划时已知常量直接不生成无关子计划;执行初始化期裁剪使用启动时已知参数,可在 EXPLAIN ANALYZE 中看到 Subplans Removed;执行过程中的动态裁剪可在参数化 Nested Loop 等场景随外层值变化,部分子计划显示 loops=0 或 (never executed)。Prepared Statement 是否使用 Custom/Generic Plan 会影响规划期可见性,但不等于 Generic Plan 一定不能执行期裁剪。
深入回答
Pruning 依赖能与分区键和 Bound 推导关系的谓词。常量谓词最适合规划期裁剪;执行参数、InitPlan 值和参数化路径可在初始化或每次参数改变时裁剪。优点是减少实际扫描、Buffer 与 I/O;缺点是大量分区仍可能带来计划构造、锁和内存开销,动态裁剪也有运行时判断成本。函数包装、隐式类型转换、不可推导表达式或缺失分区键可能阻止裁剪。替代方案是规范化参数类型、显式时间范围、生成可索引/可裁剪列或重构分区键。生产中同时采集 Planning Time、Execution Time、Subplans Removed、各子计划 loops 和实际 Buffers。
面试官真正考察什么
是否理解“计划中出现分区”与“实际执行了分区”不是同一件事,也是否会正确读计划。
常见错误回答
“Prepared Statement 使用参数,所以 PostgreSQL 无法做任何分区裁剪。”
追问
为什么同一条参数化 SQL 在前几次很快,之后 Planning Time 下降但执行变慢?
追问答案
可能从参数敏感的 Custom Plan 切换为 Generic Plan:Generic Plan 复用计划,降低重复规划成本,却无法在规划期利用具体参数;仍可能执行期裁剪,但访问路径和估算不一定适合所有参数。需用 EXPLAIN (ANALYZE, SETTINGS)、plan_cache_mode 的会话级对照实验和 pg_stat_statements 观察,而不能生产全局强制一种计划。替代方案包括拆分查询形状、提供更明确范围或使用动态 SQL的受控服务器端实现。
面试题 4:分区表的索引、主键和唯一约束有什么特殊限制?
题目
为什么分区父表上的 Unique/Primary Key 通常必须包含全部 Partition Key 列?所谓“全局唯一索引限制”意味着什么?
30 秒回答
分区父级索引是虚拟结构,每个叶分区有自己的物理索引。PostgreSQL 没有一个覆盖所有叶表、可在一次索引探测中仲裁的全局唯一索引。因此父表 Unique/Primary Key 必须包含全部分区键列,使任意两个可能冲突的键必然路由到同一叶分区,由本地唯一索引检查。
深入回答
例如按 created_at Range 分区时,UNIQUE(order_id) 不能直接保证跨月唯一;UNIQUE(created_at, order_id) 只能保证组合键唯一。优势是约束检查和叶表维护局部化;代价是业务自然键若与分区键不一致,就需要架构级仲裁。可选方案包括:让业务 ID 自带时间且由可靠生成器保证不重复;按业务 ID Hash 分区;建立非分区的 ID 注册表并在同一事务先插入;在外部强一致 ID 服务分配;或接受租户/时间域内唯一。触发器扫描全树既昂贵又可能出现并发竞态,不是等价替代。生产中还要考虑注册表热点、Failover 后序列/生成器安全、幂等和恢复验证。
面试官真正考察什么
是否理解约束正确性来自冲突键被同一仲裁结构序列化,而不只是 SQL 语法。
常见错误回答
“给每个分区都建 UNIQUE(order_id) 就能保证全局唯一。”
追问
按月分区的订单表必须保证随机 UUID 业务 ID 全局唯一,怎样做最稳妥?
追问答案
若 UUID 由高质量随机生成器产生,碰撞概率极低,但“概率极低”不等于数据库强约束。需要严格保证时,可维护小型非分区 order_id_registry(order_id primary key, created_month),在同一事务先注册再写目标分区,并让所有写入口统一经过该事务。优点是数据库可证明唯一;缺点是多一次写入、WAL、潜在热点和生命周期协调。也可按 UUID Hash 分区以原生约束唯一,但会牺牲按月 Drop。选择取决于唯一性强度与生命周期哪个是硬约束。
面试题 5:Default Partition 的作用和风险是什么?
题目
Default Partition 为什么既能提升可用性,又可能把显式故障变成更难处理的隐性积压?
30 秒回答
Default 接收不匹配任何显式 Bound 的行,能避免缺分区时立即报错;但它会掩盖未来分区遗漏、错误时区和脏键。后续 Attach 与 Default 范围重叠的新分区时,数据库必须确认 Default 中不存在该范围的数据,可能扫描并强锁 Default;若有数据还会失败,而且不会自动迁移。
深入回答
Default 的优点是提供异常缓冲与兼容过渡;缺点是数据路由不再与预期叶表一致、查询可能额外扫描 Default、Attach 验证成本不可控。Default 也不适用于 Hash Partition。生产上应把它视为“隔离区”而非正常分区:监控行数、增长速率和键范围,设置接近零的 SLO,预建未来分区,并准备分批迁移、排除约束和短维护窗口。替代方案是不要 Default,让缺分区以 Check Violation 快速失败并由持久队列重放;或者由独立维护器提前创建叶表。选择取决于写入能否重试、允许的数据路由偏差和恢复 RTO。
面试官真正考察什么
是否看到 Default 对锁、Attach 验证和事故可观测性的影响。
常见错误回答
“Default 保证永远不会漏数据,因此创建后无需维护未来分区。”
追问
Default 已积压一个月数据,怎样迁移到新分区而不直接把生产表锁死?
追问答案
先建立与目标 Bound 一致的独立 Staging Table、约束和索引;在受控并发下从 Default 分批复制/搬迁大部分数据,限制 WAL 与 Replica Lag;切入短维护窗口阻断或重定向该范围新写,迁移尾差,给 Default 加可证明不相交的约束;再 Attach Staging。优点是将长数据搬运移出强锁窗口;缺点是流程复杂且需处理幂等、双写和最终校验。不可假设 Attach 会搬数据。
18.2 原理与排障题(6 道)
面试题 6:为什么缺少分区键的查询会随保留期变慢?
题目
一张按月分区、保留五年的表,WHERE business_id = $1 为什么即使每个叶表都有索引,Planning P99 也可能持续增长?
30 秒回答
因为查询不能用月份 Bound 排除叶表。Planner 需要处理许多子关系和访问路径,Executor 也可能对每个叶索引做探测;每个分区还有关系锁、统计和计划节点成本。保留月份越多,总开销越大,即使最终只返回一行。
深入回答
性能代价分为规划与执行两段:规划阶段展开分区树、构造 Append/MergeAppend 及叶访问路径并持有关系锁;执行阶段初始化并探测多个子计划。对象多还会增加 Backend 私有内存、Catalog/Relcache 压力。优点仅是每个叶索引较小,但无法抵消 O(N partitions) 的固定开销。替代方案包括把月份编码进业务 ID、路由目录先查月份、把时间范围作为 API 契约、按业务 ID Hash 分区、保留一张全局目录或不分区。生产上要用全保留期数据测 Planning P95/P99,而不是只比较 Execution Time。
面试官真正考察什么
是否能识别“本地索引乘以分区数”的读放大与规划复杂度。
常见错误回答
“索引是 O(log n),所以无论多少分区都不会慢。”
追问
如何快速证明瓶颈主要在 Planning 而非 I/O?
追问答案
查看 EXPLAIN (ANALYZE, BUFFERS, SETTINGS, SUMMARY) 的 Planning/Execution Time、计划子节点数和实际 Buffers;结合 pg_stat_statements 的规划统计(启用相应跟踪后)、Backend CPU、磁盘 IOPS 和 wait_event。若 Planning Time 高、Buffers/I/O 低、返回前 CPU 高,说明规划是主因。还可在同会话用添加时间范围的等价查询做对照,但不能用强制 GUC 得出普适结论。
面试题 7:参数化查询没有裁剪时如何排查?
题目
Go/pgx 使用 WHERE occurred_at >= $1 AND occurred_at < $2,计划似乎保留全部分区。请给出排查顺序。
30 秒回答
先确认参数类型与分区键完全可比较、谓词直接作用于分区键且范围正确;再用真实参数执行 EXPLAIN ANALYZE,区分规划期未裁剪和执行期未执行;观察 Custom/Generic Plan;检查 enable_partition_pruning、函数包装、Cast、时区及 Prepared Statement 行为。
深入回答
第一步确认 DDL:分区键表达式、Bound、列类型和 pg_partition_tree。第二步检查 SQL 是否写成 date_trunc(occurred_at)=...、occurred_at::date=... 等难以反推 Bound 的表达式,或参数被推断为不合适类型。第三步看 Subplans Removed、loops、never executed,避免把计划中存在节点误判为实际扫描。第四步会话级比较 plan_cache_mode=force_custom_plan 与 force_generic_plan,只用于诊断。第五步排除关闭 Pruning 的 GUC。替代方案是传半开时间范围、显式类型、生成列或重写 API。生产中需保持参数化以防注入,不能通过拼接字面量“修复”。
面试官真正考察什么
SQL 语义、类型系统、计划缓存与执行期裁剪的综合诊断能力。
常见错误回答
“把参数直接拼进 SQL,就会得到最优计划。”
追问
为什么 occurred_at::date = $1::date 可能比半开区间差?
追问答案
它对分区键应用表达式,Planner 未必能把结果转换为与原始 timestamptz Range Bound 等价的范围;也可能破坏普通列索引的可用性并引入 Session TimeZone 语义。推荐由应用计算明确的 UTC [start, end),使用 occurred_at >= $1 AND occurred_at < $2。如业务确需本地日期,可建立语义固定的生成列并按该列设计分区/索引,但要审查 DST 与时区规则。
面试题 8:ATTACH、DETACH、DETACH CONCURRENTLY、DROP 的锁和状态如何分析?
题目
请比较四类生命周期 DDL 的主要目的、锁风险与在线性。
30 秒回答
ATTACH 把已有表纳入分区树,需要验证 Bound,若缺少可证明约束会扫描待 Attach 表;存在 Default 时还可能验证并强锁 Default。普通 DETACH 从父树移除叶表,父表锁较强。DETACH ... CONCURRENTLY 用更弱的父表锁分阶段完成,但会等待使用旧分区树的事务,不能在事务块中运行,失败可能留下 Pending Detach,需 FINALIZE。DROP 直接删除叶表对象,元数据和文件回收快,但通常会对父表造成强锁风险。
深入回答
这些命令的优势是将生命周期动作从逐行 DML 转为关系级元数据操作,显著降低 Heap/Index 逐行维护、Dead Tuple 和常规 Vacuum 压力。缺点是 DDL 锁具有“排队放大”:一个等待中的强锁可能阻挡后续本可并发的请求。DETACH CONCURRENTLY 降低父级阻塞范围,但并非无锁,也不适用于含 Default 的某些情形;第二阶段和叶表处理仍有锁要求。替代方案是在副本/归档系统保留历史、分批 Delete、逻辑归档或维护窗口普通 Detach。生产中必须设 lock_timeout、观察长事务和 Pending 状态、准备取消与 Finalize 路径。
面试官真正考察什么
是否理解锁强度、验证扫描、事务快照和 DDL 状态机,而不只会背命令。
常见错误回答
“带 CONCURRENTLY 就绝不会等待或阻塞。”
追问
DETACH CONCURRENTLY 被取消后如何判断是否已完成?
追问答案
不能仅凭客户端错误判断。查询 pg_inherits、pg_partition_tree、pg_class.relispartition 与继承项 Pending Detach 状态,确认叶表是否仍属于父树;若处于未完成状态,按官方语义执行 ALTER TABLE ... DETACH PARTITION ... FINALIZE,同时检查约束与应用访问路径。Commit/网络结果不确定时应基于目录状态做幂等复核,而不是盲目重复 DDL。
面试题 9:为什么 Drop 分区通常比批量 Delete 高效?何时仍应 Delete?
题目
从 Tuple、Index、WAL、Vacuum、锁和复制角度比较两者。
30 秒回答
Delete 逐行产生新 MVCC 状态、更新每个索引、写大量 WAL,留下 Dead Tuple 并需要 Vacuum,且长事务会延迟空间回收;Drop/Detach 以整关系为单位移除数据对象,通常工作量与关系数而不是行数相关,因此更适合按边界淘汰整块历史。
深入回答
逐行 Delete 的优点是可精确删除任意子集、能触发既有行级业务语义;缺点是写放大、锁持续时间、Replica Lag、Bloat、Checkpoint 和 Vacuum 压力。Drop 的优点是快、空间回收直接、无逐行扫描;缺点是边界必须与保留策略一致,DDL 锁和误删影响巨大,行级触发器/CDC 语义与 Delete 不同,PITR/备份恢复也需验证。可先 Detach 做隔离与归档,再延迟 Drop,以获得回滚窗口。仍应 Delete 的场景包括 GDPR 针对单用户删除、部分范围纠错、分区内少量数据清理。生产上先验证归档、Replica Lag、锁窗口和审计要求。
面试官真正考察什么
是否能把性能收益与语义差异、恢复风险一起分析。
常见错误回答
“Drop 不写 WAL,所以副本完全不受影响。”
追问
为什么“先 Detach、观察一段时间、再 Drop”常比直接 Drop 安全?
追问答案
Detach 先让父表查询不可见,但保留独立表,可做校验、导出、临时恢复访问或重新 Attach;直接 Drop 的逻辑回滚路径只剩备份/PITR。代价是仍占空间、需要权限隔离并防止应用误访问。应规定隔离期、校验和、归档清单、最终删除审批与恢复演练。
面试题 10:分区统计与 Autovacuum 有哪些陷阱?
题目
为什么所有叶分区都有最新 last_autoanalyze,跨分区查询仍可能估算错误?
30 秒回答
Autovacuum/Autoanalyze 主要按叶关系变化触发;叶表变化不会自然按同样阈值触发父级分区表的 Analyze。跨分区 Planner 既依赖叶统计,也可能依赖父级统计,因此需要在批量装载、Attach/Detach 或分布变化后主动 ANALYZE 父表,并单独监控父级统计新鲜度。
深入回答
叶统计描述局部分布,父级统计帮助估算全树条件和 Join。小叶可能因固定阈值或低变化比例长期不触发 Analyze;新 Attach 表若未提前 Analyze,会以差统计加入计划。过度分区还让 Worker 调度、Catalog 查询和维护窗口碎片化。优点是叶级 Vacuum 可隔离工作集;缺点是维护对象多且父/叶状态分裂。替代方案包括较粗粒度分区、装载后显式 Analyze、为特定叶调节表级 Autovacuum 参数、扩展统计或汇总表。生产上监控 n_mod_since_analyze、last_autoanalyze、父级 last_analyze、估算/实际比以及 XID Age,绝不能因分区而关闭 Autovacuum。
面试官真正考察什么
是否理解统计触发机制与物理叶表的维护边界。
常见错误回答
“父表没有数据文件,所以不需要 Analyze。”
追问
新月分区离线装载并 Attach 前应做什么?
追问答案
在独立表上完成数据校验、Bound 对应的 Check Constraint、索引和 ANALYZE;确认没有越界行后 Attach,以减少验证扫描并让它带着可用统计进入树。Attach 后根据跨分区查询需要再 Analyze 父表。若数据仍持续变化,要恢复合适的 Autovacuum 设置;不能把离线装载时临时关闭的参数遗留到生产。
面试题 11:Partition-Wise Join/Aggregate 为什么默认不应全局开启?
题目
解释它们可能加速什么,又为什么会增加 Planning Time 和内存。
30 秒回答
当两侧按兼容键和边界分区时,Partition-Wise Join 可把大 Join 拆成叶级 Join;Aggregate 可先在分区内聚合,减少中间数据。但它们可能为每个分区创建独立计划节点、Hash/Sort 和内存结构,规划 CPU与运行时内存随分区数增长,所以默认关闭,应该按查询或角色经压测启用。
深入回答
收益来自数据局部性、并行机会和减少全局中间结果,尤其适合可裁剪、边界对齐的大型分析。成本包括更大的计划树、更多 work_mem 消费点、Temporary File、Backend RSS、锁对象和计划不稳定性;分区不对齐时收益有限。PostgreSQL 18 改进了大量分区规划和更多 Partition-Wise Join 场景,但不是取消复杂度。替代方案是常规 Parallel Hash Join、预聚合、物化视图、分析副本或数仓。生产中用最坏分区数和并发度测 Planning P99、Peak Memory、Temp、CPU、执行时长,局部 SET LOCAL 而非全局打开。
面试官真正考察什么
是否理解查询优化开关存在资源乘法,能进行吞吐而非单查询视角评估。
常见错误回答
“把 Join 拆小一定更省内存,所以应该永久开启。”
追问
单条报表变快 30%,但主机更容易 OOM,应如何决策?
追问答案
评估的是并发吞吐和 SLO,不是单查询最佳值。限制报表并发、路由分析副本、局部启用、降低单查询并行度或重写为分阶段聚合;记录 Backend Peak Memory 与 Temp。若总资源成本和故障半径仍过高,应回退常规计划。任何 work_mem 调整都要乘以计划节点、并发查询和并行 Worker 数量估算。
18.3 架构设计题(4 道)
面试题 12:如何为五年事件数据选择按天、月还是年分区?
题目
给出一个可操作的决策方法,而不是固定阈值。
30 秒回答
先从保留/归档最小粒度、单分区数据量与索引维护时间、典型查询窗口、叶表总数和运维频率共同决定。天适合短窗口、高精度淘汰但对象多;月通常是通用折中;年对象少但单叶巨大、淘汰不灵活。用“保留期 ÷ 粒度 × 子分区数 × 每叶索引数”做对象预算,再用真实工作负载压测。
深入回答
如果合规要求每日删除,月分区仅靠 Drop 无法满足;若查询多为整月且保留十年,日分区可能造成数千叶和大量索引;年分区虽简单,但单次 Vacuum、Reindex、归档和热点索引可能过重。还要考虑行宽、每日峰值、存储、CPU、内存、Replica 带宽、备份窗口和 SLO。可采用月分区加短期日级 Staging、月内二级分区、滚动合并或冷库迁移。生产设计要给出上限告警和未来扩展路径,不能把当前数据量线性外推为永久安全。
面试官真正考察什么
容量建模、生命周期和运维可控性的平衡能力。
常见错误回答
“每天超过一百万行就按天,否则按月。”
追问
数据量季节性波动 20 倍,固定月分区是否仍合理?
追问答案
可能合理,因为 Bound 不要求每个叶同样大;先确认峰值月是否超出索引构建、Vacuum、备份和查询 SLO。极端峰值可只对子树做更细子分区,但会形成非均匀拓扑,维护器和监控需理解。另一方案是统一按周或按天。选择应以峰值压测和对象预算为证据,不应为了视觉均匀制造大量空分区。
面试题 13:如何同时满足按月淘汰和业务 ID 全局唯一?
题目
设计一个写入与查询架构,并说明失败模式。
30 秒回答
保留 events 按月 Range 以支持生命周期,同时建立非分区 event_id_registry(event_id primary key, event_month, state) 作为全局唯一和路由仲裁。单事务先插 Registry,再插目标分区;点查先由 Registry 得到月份后精准访问。写入必须幂等,Commit 结果不确定时先查 Registry 状态,不盲重试。
深入回答
Registry 的优点是数据库级全局唯一、点查可裁剪、生命周期与业务 ID 解耦;缺点是额外写放大、WAL、索引增长和潜在热点。可用随机/UUIDv7 ID 分散 B-tree 插入特征,并按保留策略决定 Registry 是否长期保留或只保留去重窗口。事务失败时两表原子回滚;网络在 Commit 后断开时,客户端需以 Idempotency Key/ID 查询确认。替代方案是按 ID Hash 分区、ID 中编码月份、强一致外部 ID 服务,或接受概率唯一。HA 中需保证 Registry 与数据表同一 Primary/事务域,否则会引入分布式一致性问题。
面试官真正考察什么
约束、路由、幂等和 Failover 下提交不确定性的综合设计。
常见错误回答
“UUID 不会重复,所以不用约束,也不用处理 Commit 超时。”
追问
Registry 成为写热点怎么办?
追问答案
先用指标确认是索引页、WAL、锁还是容量问题;随机键通常不会形成单一右端页热点,但仍有 WAL 和单 Primary 上限。可批量写、优化行宽、减少无用索引、按业务域拆分 Registry,或将唯一 ID 由可证明无冲突的分配算法生成,从而降低数据库仲裁需求。若跨分片保持强全局唯一,需专用分配服务或全局协调,代价是可用性和延迟;不能用应用层先查后插替代唯一约束。
面试题 14:如何设计热、温、冷分区与历史归档?
题目
请覆盖 Tablespace、备份、Replica、恢复和数据删除流程。
30 秒回答
热分区放低延迟存储并保留完整索引;温分区可移到容量型 Tablespace、降低索引集合和查询并发;冷数据先 Detach,生成带校验和的归档并做恢复演练,再延迟 Drop。Tablespace 只是文件位置,不是备份;物理副本通常仍复制其变化,Failover 节点必须具备一致的 Tablespace 映射和文件系统。
深入回答
生命周期状态可定义为 Active → Read Mostly → Detached Quarantine → Archived → Dropped。每次状态转换都记录边界、行数、最小/最大键、Schema、校验和和对象清单。Move Tablespace 会产生大量 I/O、锁与复制压力,应安排窗口并限流;冷分区若仍在线查询,须维护必要统计和索引。归档格式需考虑未来 Schema、时区和权限恢复。替代方案是对象存储+查询引擎、独立归档 PostgreSQL、逻辑复制到分析平台。RPO/RTO 要分别说明:主库 PITR 能否恢复误 Drop,归档能否在目标时间重建可查询表。生产删除必须经过双人审批与恢复抽检。
面试官真正考察什么
是否把存储分层与可恢复性、HA 拓扑和审计闭环联系起来。
常见错误回答
“把旧分区移到便宜 Tablespace 后就等于完成备份。”
追问
Failover 后新 Primary 缺少冷 Tablespace 挂载会怎样?
追问答案
数据库可能无法访问相应关系,启动、查询或恢复流程失败,具体表现取决于缺失文件和访问路径;这不是可接受的“只影响冷数据”假设。所有候选节点必须有一致 Tablespace 路径、权限、容量和监控,或不要让 HA 主集群依赖仅单机可见的存储。Switchover 演练应实际读取冷热分区,而不只检查节点角色。
面试题 15:什么时候应从分区演进到分片?
题目
请解释两者边界,并设计渐进式扩展路线。
30 秒回答
分区仍在同一个 PostgreSQL Cluster/Primary 内,共享 CPU、内存、WAL、Checkpoint、故障域和写入上限;分片把数据与负载分散到多个独立 Primary。若瓶颈是生命周期和单表维护,优先分区;若单 Primary 的写吞吐、容量、WAL、恢复时间或故障域已无法满足,应进入分片,而不是继续增加叶表。
深入回答
判断依据包括峰值 CPU、WAL 生成与复制带宽、存储 IOPS/容量、Checkpoint、Vacuum、备份恢复 RTO、连接和单节点故障影响。分片收益是水平扩展和故障域隔离;代价是跨分片事务、全局唯一、Join/Aggregate、重平衡、路由、备份和 Failover 复杂度。渐进路线可先在单节点按未来 Shard Key 建模、消除跨租户事务、建立路由层和全局 ID 策略,再按租户/Hash 迁移到多个 Cluster。替代方案包括读副本、缓存、分析平台、垂直扩容和工作负载隔离。生产上必须定义每个分片的 RPO/RTO、Fencing、再均衡和热点租户迁移机制。
面试官真正考察什么
是否能区分逻辑数据组织与物理扩展边界,并量化复杂度转折点。
常见错误回答
“把表分成一万个分区就等于分片,能够使用一万台机器。”
追问
按租户分片后,跨租户报表怎么办?
追问答案
在线事务路径避免跨分片全局查询;通过 CDC/逻辑复制将数据汇聚到分析系统,或由查询协调层并行下推后做受控归并。前者一致性有延迟但可扩展,后者实时性高但扇出、部分失败和资源控制复杂。应明确报表的一致性等级、最大扇出、超时、分页和结果去重,不能让任意 API 同步扫描所有分片。
19. 练习与参考答案
19.1 理论题(5 道)
理论题 1:判断是否需要分区
一张审计表有 3,000 万行、总计 45 GB,每天新增 20 万行,查询大多按主键点查,历史永久保留,现有查询与 Vacuum 均满足 SLO。是否应仅因“超过千万行”立即分区?请列出决策证据。
参考答案
不应仅按行数决定。当前访问以主键点查为主,没有按时间淘汰,分区未提供明显生命周期收益;若查询不带分区键,反而可能增加 Planning 和多索引探测。需要收集行宽、索引大小、缓存命中、P95/P99、写入峰值、Vacuum/Freeze 时长、备份与恢复 RTO、未来增长、DDL 窗口和硬件余量。若单表仍满足 SLO,可保持单表并优化索引、Autovacuum、归档和容量告警。只有出现明确的维护、保留、冷热或单关系操作瓶颈,并且分区键与访问模式一致时再迁移。
理论题 2:判断约束是否合法
表按 occurred_at Range 分区。以下父级约束哪些可直接建立,为什么?
UNIQUE (event_id)
UNIQUE (occurred_at, event_id)
PRIMARY KEY (tenant_id, event_id)
PRIMARY KEY (occurred_at, tenant_id, event_id)
参考答案
在普通列 Range 分区前提下,父级 Unique/Primary Key 必须包含全部 Partition Key 列,因此第二和第四个包含 occurred_at,可满足结构性要求;第一和第三缺少 occurred_at,不能在父级形成跨叶全局唯一约束。仍需检查分区键是否使用表达式及版本具体限制。合法不等于业务语义正确:UNIQUE(occurred_at,event_id) 只保证组合唯一,若同一 ID 在不同时间可重复,仍不满足全局唯一。需要全局唯一时采用 Registry、调整分区键或外部强一致 ID 方案。
理论题 3:解释计划
查询计划中出现 60 个叶分区节点,但 Subplans Removed: 58,剩余两个节点中一个 loops=1,另一个 loops=0。是否扫描了 60 个分区?
参考答案
没有。计划文本包含候选节点不代表执行访问。58 个在执行初始化阶段被移除;loops=0 的节点没有执行;只有 loops=1 的节点实际运行。仍要注意规划阶段可能处理了较多分区并产生 Planning Time、锁和内存成本。应结合每节点 Buffers、Actual Rows、Planning/Execution Time 和参数计划类型判断,而不能只数计划文本节点。
理论题 4:分析 Default 风险
团队主张:“Default Partition 能保证写入永不失败,因此应永久保留且无需告警。”请反驳。
参考答案
Default 只能接收未匹配显式 Bound 的行,不能保证其他约束、磁盘、权限、复制或事务都不失败;Hash 分区也不支持 Default。它会隐藏缺分区、错误时区和越界数据,使路由偏离预期。后续 Attach 重叠 Bound 时可能扫描并强锁 Default,存在数据还会失败,并且不会自动迁移。正确做法是预建未来分区、对 Default 行数和增长即时告警、建立迁移 Runbook;若业务可重放,甚至移除 Default 以快速失败。
理论题 5:评估 Partition-Wise Aggregate
一个报表跨 1,000 个月/子分区执行高基数 Group By。为什么启用 Partition-Wise Aggregate 既可能更快,也可能更危险?
参考答案
它可在每个叶内先做 Partial Aggregate,降低跨叶中间数据并利用并行性;但可能为大量叶创建独立 Hash/Sort/聚合节点,每个节点都有内存与临时文件风险,计划树和 Planning Time 也增大。并发报表时资源成本会乘以连接数和 Parallel Worker。应在真实分区数、基数、数据倾斜和并发下比较 Execution、Planning、Peak Memory、Temp、CPU 和吞吐,并以 SET LOCAL 局部启用。替代方案是预聚合、物化视图、分析副本或数仓。
19.2 实验题(3 道)
实验题 1:比较分区粒度与 Planning Time
构造相同总数据量、分别按日和按月分区的两棵事件表,比较三类查询:单日、单月、无时间谓词点查。要求给出可复现方法和记录项。
参考答案
- 在同一 PostgreSQL 版本、配置和硬件建立两棵等价表;总行数、行宽、索引和数据分布一致。
- 日表至少覆盖两年以上,使叶数有明显差异;月表覆盖同范围。
- 分别
ANALYZE所有叶和父表,记录pg_partition_tree叶数和总大小。 - 使用参数化半开区间执行:单日、单月、仅
event_id。每类查询先冷缓存再热缓存,重复足够次数,并记录客户端 P50/P95/P99。 - 保存
EXPLAIN (ANALYZE, BUFFERS, WAL, SETTINGS, VERBOSE, SUMMARY),比较 Planning Time、Execution Time、Subplans Removed、实际叶数、Buffers、CPU/I/O/Wait Event。 - 预期不是固定胜负:单日查询日分区可能裁剪更精细;单月查询月分区对象更少;无时间点查日分区通常放大得更严重。最终结论必须结合保留粒度和对象运维成本。
- 清理实验对象;禁止在生产创建数千测试分区。
实验题 2:在线建立父级分区索引
为已有 24 个大叶分区的父表增加 (tenant_id, occurred_at) 索引,设计避免长时间递归强锁的流程。
参考答案
推荐流程:
- 先确认磁盘、WAL、Replica Lag、长事务和索引构建预算,设置适度
lock_timeout。 - 在父级创建
ON ONLY的分区索引,使其初始未完整有效;具体语法先在目标版本测试。 - 对每个叶表逐个或低并发执行
CREATE INDEX CONCURRENTLY,索引名固定由受控程序生成,不拼接用户输入。 - 每个叶索引完成后检查
pg_index.indisvalid/indisready、大小和重复定义;失败的 Invalid Index 按 Runbook 清理重建。 - 逐个
ALTER INDEX parent_index ATTACH PARTITION leaf_index;全部 Attach 后验证父级索引有效。 - 对代表性 SQL执行 Explain,监控 WAL、CPU、I/O、Replica Lag 和 DDL Lock Wait。
- 优点是把长扫描拆为叶级 Concurrent Build;缺点是步骤多、需状态机和幂等恢复。不要假设父级可直接
CREATE INDEX CONCURRENTLY递归完成。
实验题 3:验证维护器的 Commit 结果不确定处理
基于本章 Go 维护器,设计故障注入,证明客户端在 Commit 返回网络错误时不会盲目重复造成错误状态。
参考答案
- 在测试环境使用代理或容器网络,在数据库提交完成后、客户端收到结果前中断连接;不要在生产故障注入。
- 维护动作由固定数据库函数按月份幂等创建,并受 Session Advisory Lock 串行化。
- 客户端 Commit 返回错误后,将状态标记为 Unknown,而非 Failed;重新建立连接查询
pg_partition_tree、Bound、索引和 Tablespace,判断目标月份是否已完整存在。 - 若完整存在,按成功收敛;若不存在且无 Pending/半成品,再按有限重试执行;若部分状态存在,进入人工/自动修复分支。
- 记录 SQLSTATE、连接错误、目标月份、目录复核结果和重试次数。验证并发运行两个维护器时只有一个持有 Advisory Lock。
- 预期结果是最终只有一个合法分区,边界无重叠、索引完整;不能把“Commit 报错”直接解释为“事务未提交”。
19.3 排障题(2 道)
排障题 1:月初写入开始报错
00:00 UTC 后,插入按月分区表大量失败,应用只记录了 SQLSTATE 23514,没有 Default Partition。请给出止损和根因排查。
参考答案
23514 是 Check Violation;在该场景首先怀疑没有匹配的分区,但仍须查看服务端错误字段和目标键值确认。步骤:
- 限制重试并启用 Backpressure,避免所有 goroutine/连接反复失败;将可重放事件写入持久队列。
- 查询
pg_partition_tree、pg_get_expr(relpartbound, oid),确认当前和未来 Bound;检查 Session TimeZone 与应用参数。 - 查看独立维护任务最后成功时间、权限、Advisory Lock、Failover 后调度器单活与 Secret。
- 若确实缺分区,在专用运维路径创建边界、索引和约束;设
lock_timeout,先清除长事务/DDL 队列风险。普通请求路径不得自行 DDL。 - 创建后用边界前后测试值、
tableoid和 Explain 验证,再逐步重放队列。 - 根因修复是预建未来窗口、边界覆盖告警、Failover 后维护器验证和月初演练。盲目加 Default 会把失败转成潜在积压,不是自动根治。
排障题 2:查询 Execution 很快但端到端 P99 很高
一张有 20,000 个叶分区的表,数据库 Explain 显示 Execution Time 20 ms,应用 P99 3 s,磁盘不忙。如何定位?
参考答案
- 先把端到端拆为 Pool Acquire、网络、Planning、Execution、Row Decode;检查 pgxpool
EmptyAcquireWaitTime、Acquired/Idle/Total Conns 与 Context Timeout。 - 查看 Explain 的 Planning Time、计划节点数、
Subplans Removed;无分区键时很可能规划数秒而执行很短。 - 用
pg_stat_activity看 active Backend、Wait Event 和查询开始时间;用系统指标看 Backend CPU/RSS,而不是只看 IOPS。 - 检查是否全局打开 Partition-Wise Join/Aggregate,是否从月分区扩成日×Hash,是否有 Generic Plan/类型问题。
- 临时止损:要求时间范围、限制跨全树并发、关闭在线角色的高成本 Partition-Wise 开关、把报表路由副本。
- 根本修复:减少叶数、改变分区粒度、增加路由目录或重构分区键,并把 Planning P95/P99 纳入发布门禁。单纯增大连接池会放大 CPU/内存竞争。
19.4 系统设计题(1 道)
系统设计题:全球多租户事件平台的分区与生命周期
设计一个 PostgreSQL 事件平台:峰值 150,000 行/秒,事件平均 500 B;在线查询最近 90 天,合规要求原始数据保留 3 年;常见查询是租户+时间范围,偶尔按全局 event_id 点查;单租户流量最大可占 15%;要求同 Region RPO 接近 0、跨 Region 灾备 RPO 5 分钟、核心写入月初不能因缺分区中断。请给出数据模型、分区、唯一性、维护、HA、归档、监控和扩展路线。
参考答案
1. 先验证单 Primary 边界
150,000 行/秒 × 500 B 仅为原始载荷约 75 MB/s,尚未计 Heap Header、对齐、索引、WAL、Full Page Image、Replica 和峰值;必须通过真实 Schema、批次、索引数、压缩与硬件压测。若单 Region 单 Primary 无法满足 WAL/CPU/存储/RTO,直接进入按租户或 Hash 分片,而不是仅靠分区。
2. 表与分区
CREATE TABLE events (
tenant_id bigint NOT NULL,
event_id uuid NOT NULL,
occurred_at timestamptz NOT NULL,
event_type text NOT NULL,
payload jsonb NOT NULL,
PRIMARY KEY (tenant_id, occurred_at, event_id)
) PARTITION BY RANGE (occurred_at);
一级按 UTC 日或周/月选择:90 天在线高吞吐若单日大小仍可维护,可按日;三年全挂在线会形成约 1,095 叶,尚需测 Planning 与对象预算。更稳妥是只让最近 90 天在 OLTP 树中保持日级,较老数据 Detach 后进入归档 PostgreSQL/对象存储;不要在一个无限增长父树挂满所有历史。若单日热点叶过大,可在日内按 tenant_id Hash 做有限子分区,但需证明收益并设置对象上限。
常用叶级索引可为 (tenant_id, occurred_at DESC)、必要时 (tenant_id, event_type, occurred_at DESC);避免给 JSONB 无差别建大 GIN。BRIN 可用于大而时间相关的冷叶。分区不是索引替代品。
3. 全局 Event ID
因为点查只给 event_id 且时间 Range 分区无法原生 UNIQUE(event_id),建立 event_registry(event_id primary key, tenant_id, occurred_at, state)。同一事务注册并写事件;点查先路由到租户/日期。若业务允许由 UUIDv7/强 ID 服务保证唯一,也仍需明确它是概率/算法保证而非父表全局索引。Registry 可按业务要求保留三年或更久,需容量和热点压测。
4. 写入与幂等
Go 使用 pgxpool、有界 Worker 和批量 Copy/Batch;每批有 Context 超时、Admission Control 和持久队列。请求路径不执行 DDL。以 event_id/Idempotency Key 处理重试;Commit 错误后查询 Registry 判定 Unknown 结果。对 40001/40P01 只重试完整事务,指数退避和抖动,防止风暴。
5. 自动维护
独立维护器每天预建至少未来 30~90 天,使用 Session Advisory Lock、固定 Security Definer 函数、严格月份/日期白名单和目录复核。Default 的两种策略:
- 首选无 Default,让漏分区快速失败到持久队列,确保错误可见;
- 若业务必须持续接收,Default 仅作异常隔离区,任何行立即 P1 告警,并有自动迁移 Runbook。
维护器在 Planned Switchover/Unplanned Failover 后验证单活、权限、未来覆盖和 Tablespace。
6. 生命周期与归档
热:最近数天置于低延迟存储;温:90 天内保留在线、降低非必要索引;冷:到期叶先 DETACH CONCURRENTLY,校验行数/边界/Checksum,导出对象存储或归档库并完成抽样恢复,隔离期后 Drop。三年到期执行不可逆删除审批。Tablespace 不是备份;所有 HA 候选节点需一致挂载。
7. HA 与灾备
同 Region 用同步物理复制满足接近零 RPO,但需明确同步副本故障时是阻塞写入还是降级;采用 Patroni/等价控制面、DCS、Fencing 和连接重定向。跨 Region 异步物理/逻辑复制满足目标 RPO 5 分钟,持续监控 Replay Lag 与 WAL 保留。每个分片独立定义 RTO/RPO。PITR 和归档恢复必须演练误 Drop 分区;应用处理旧连接与 Commit 结果不确定。
8. 监控
未来 Bound 覆盖天数、Default 行数、叶数/索引数、Planning P95/P99、实际访问叶数、写入 P99、WAL/s、Checkpoint、Replica Lag、Autovacuum/Analyze、新分区创建状态、DDL Lock Wait、最长事务、Pool Acquire Wait、Backend RSS、归档校验和恢复成功率。
9. 扩展路线
先按未来 Shard Key——通常 tenant_id——保证事务局部性。单 Primary 接近 CPU/WAL/容量/RTO上限时,按一致性 Hash 或租户目录分片;15% 大租户支持独占分片。跨租户分析通过 CDC 进入分析平台,不在 OLTP 同步扇出。分片会增加全局 ID、再均衡、备份、Failover 和跨分片查询复杂度,应以容量模型而非表行数触发。
20. 本章检查清单
分区模型与约束
- 我能够根据查询谓词、保留粒度和对象预算选择 Range、List 或 Hash,而不是按行数套模板。
- 我能够解释 Partition Key、Partition Bound、Default Partition 和 Tuple Routing 的关系。
- 我能够设计多级分区,并计算“一级分区 × 二级分区 × 索引数”的对象膨胀。
- 我能够解释跨分区 UPDATE 为何可能变成旧叶删除与新叶插入。
- 我能够判断父级 Unique/Primary Key 是否包含全部分区键,并说明全局唯一索引限制。
- 我能够为全局业务 ID 比较 Registry、Hash 分区、ID 编码和外部 ID 服务。
- 我能够分析 Foreign Key 在分区路由、跨叶更新和生命周期 DDL 中的影响。
计划与性能
- 我能够区分 Planning-Time、初始化期和动态 Execution-Time Pruning。
- 我能够从
Subplans Removed、loops、never executed、Buffers 和 Planning Time 判断实际访问分区。 - 我能够解释为什么查询不包含分区键时会产生计划和索引探测放大。
- 我能够证明分区不能替代叶级索引。
- 我能够在真实保留期下测 Planning P50/P95/P99,而不是只测两个叶表。
- 我能够评估 Partition-Wise Join/Aggregate 的计划节点、内存、Temp 和并发成本。
- 我能够分析过度分区对 Catalog、Relcache、锁、Backend 内存和运维任务的影响。
- 我能够结合 CPU、内存、shared_buffers、OS Page Cache、I/O、AIO、WAL、Checkpoint、Vacuum 和网络分析分区性能。
生命周期、并发与高可用
- 我能够比较批量 Delete、Detach、Detach Concurrently 和 Drop 的语义、锁、WAL 与恢复代价。
- 我能够在 Attach 前准备可证明 Bound 的约束,并识别 Default 验证风险。
- 我能够识别等待中的强 DDL 锁对后续请求形成的队列放大。
- 我能够查找长事务、Blocker、Pending Detach 和失败的 DDL 状态。
- 我能够制定热、温、冷、Detached、Archived、Dropped 的状态机和校验门禁。
- 我能够说明 Tablespace 不是备份,并验证 Failover 节点的路径与数据可访问性。
- 我能够分析 Drop/Detach 对物理复制、逻辑复制、PITR、RPO 和 RTO 的影响。
- 我能够在 Planned Switchover 和 Unplanned Failover 后验证维护器单活和未来分区覆盖。
统计、维护与 Go
- 我能够分别检查叶级 Autovacuum/Analyze 与父表统计新鲜度。
- 我能够在离线装载、Attach/Detach 和分布显著变化后安排合适的 Analyze。
- 我能够实现不在普通请求路径运行的独立分区维护任务。
- 我能够在 Go 中使用
context.Context、pgx/v5、pgxpool、参数化 SQL 和有界并发。 - 我能够使用 Session Advisory Lock 防止多个维护器并发执行 DDL。
- 我能够用
errors.As、*pgconn.PgError和 SQLSTATE 分类错误,而不匹配错误文本。 - 我能够对
40001、40P01做有限完整事务重试,并处理 Commit 结果不确定。 - 我能够把连接池 Acquire Wait、活跃连接、排队请求和数据库活跃查询区分开。
生产处置
- 我能够复现实验中的分区裁剪、Default Attach 等待/失败和 Detach 等待。
- 我能够在事故中先取消危险 DDL 队列、限流和保护 Replica,而不是继续重试。
- 我能够为未来边界、Default 增长、叶数、Planning Time、DDL 锁和 Replica Lag 设置告警。
- 我能够判断问题应由分区、索引、归档、读副本、分析平台还是分片解决。
21. 官方资料与继续阅读
以下资料均以 PostgreSQL 18 为主基线;访问日期:2026-06-20。
PostgreSQL 18 官方文档
- Declarative Partitioning:https://www.postgresql.org/docs/18/ddl-partitioning.html
CREATE TABLE(Partition Bound、Default、约束):https://www.postgresql.org/docs/18/sql-createtable.htmlALTER TABLE(Attach/Detach/Finalize 与锁):https://www.postgresql.org/docs/18/sql-altertable.html- Query Planning 配置(Partition Pruning、Partition-Wise Join/Aggregate、Plan Cache):https://www.postgresql.org/docs/18/runtime-config-query.html
- Routine Vacuuming 与统计:https://www.postgresql.org/docs/18/routine-vacuuming.html
ANALYZE:https://www.postgresql.org/docs/18/sql-analyze.htmlpg_partitioned_table:https://www.postgresql.org/docs/18/catalog-pg-partitioned-table.htmlpg_inherits:https://www.postgresql.org/docs/18/catalog-pg-inherits.htmlpg_class:https://www.postgresql.org/docs/18/catalog-pg-class.html- 分区信息函数
pg_partition_tree等:https://www.postgresql.org/docs/18/functions-admin.html - 显式锁与锁冲突矩阵:https://www.postgresql.org/docs/18/explicit-locking.html
EXPLAIN:https://www.postgresql.org/docs/18/sql-explain.htmlpg_stat_activity:https://www.postgresql.org/docs/18/monitoring-stats.html- Tablespace:https://www.postgresql.org/docs/18/manage-ag-tablespaces.html
PostgreSQL 14—18 Release Notes
- PostgreSQL 14:https://www.postgresql.org/docs/release/14.0/
- PostgreSQL 15:https://www.postgresql.org/docs/release/15.0/
- PostgreSQL 16:https://www.postgresql.org/docs/release/16.0/
- PostgreSQL 17:https://www.postgresql.org/docs/release/17.0/
- PostgreSQL 18:https://www.postgresql.org/docs/release/18.0/
pgx/v5 官方资料
- pgx GitHub:https://github.com/jackc/pgx
pgxpoolAPI:https://pkg.go.dev/github.com/jackc/pgx/v5/pgxpoolpgconn.PgErrorAPI:https://pkg.go.dev/github.com/jackc/pgx/v5/pgconn